Blogger で関連記事を表示したい。





以前は、はてなブログで書いておりました。はてなブログには関連記事を表示するガジェットが提供してあります。 そこで Blogger でも同様に関連記事を表示したいと思い検索したところ 【Blogger】関連記事表示のお手軽プラグイン(改良版) という記事を見つけました。 そこに載っていたコードを参考に 1日がかりで 関連記事を表示するための JavaScript のコードを書きました。 その中でコード内で使っている API について調べたところ Google Data API v2.0 というらしく、2024 年 9 月 30 日 にサポートが終了するらしいです。以下参照。

リファレンス ガイド

重要: Google Data API v2.0 のサポートは 2024 年 9 月 30 日に終了します。機能を引き続き利用するには、v2.0 Google Data API を使用するアプリケーションを最新の API バージョンに更新してください。 最新バージョンを確認するには、左側のナビゲーション バーにあるリンクを使用してください。注: 一部の GET リクエスト(投稿の一覧表示など)は引き続きフィード URL としてサポートされますが、その動作に若干の違いがあります。詳細については、Blogger ヘルプのドキュメントをご覧ください。


「じゃあ新しい API 使えばええやん」と思うかもしれませんが、 新しい API である Blogger API バージョン 3.0 は、他の REST API と同様に OAuth 認証が必要です。 OAuth 認証をフロントエンド(HTML内)で使う人はいません。なぜならメールアドレスとパスワードを公開するようなものなので。


コードを書くために使った1日という時間を返せと思いつつ、前向きに関連記事表示を実現したいと考えていたところ API の一部であるフィードについてはどうなるんだろうと思いまして、 調べてみると GData API バージョン 2.0 の URL がフィードの URL として使用されています。このようなフィードも利用できなくなりますか? に以下のように書かれていました。

このような URL のフィードは引き続きサポートが提供されます。ただし、blogspot.comのフィードと同様の応答(例: https://{your_subdomain}.blogspot.com/feeds/posts/default)となります。

つまり「フィードに関しては今後も提供しますよ」ということです。なのでフィードから取得する方法で関連記事を取得するように書き換えました。






注意事項

当ブログは Emporio テーマを使用しています。 他のテーマを使っている方は動作しない可能性があります。 いくつか設定を書いていますので各自で適宜変更して下さい。


フィードの提供について

フィードは提供すると書いてありましたがパラメーターについては言及されていません。 もしかすると2024年9月30日以降は下記のコードも動作しなくなる可能性はあります。 その場合はラベルのページをスクレイピングでもして取得しようと思いっています。


免責事項

コードの使用によるいかなる損害についても一切責任は負いません。



JavaScript コード

コードを長々と書いていますが内容としては、表示されている記事と同じラベルを持つ記事の一覧を JSON 形式で取得して、そこからランダムに選んで関連記事と称して表示しているだけです。

以下のコードを BloggerレイアウトSidebar (Item Page) の下にある + ガジェットを追加HTML / JavaScript を追加して、コンテンツのところにコピペして保存して下さい。 うまく動作すれば本文とコメントの間に関連記事が表示されるはずです。


<style type="text/css">
.related-posts {
  margin-top: 20px;
  margin-bottom: 20px;
  border-top: 1px solid #EEEEEE;
}
  .related-posts-title {
    font-size: 18px;
    font-weight: bold;
    padding-top: 14px;
    padding-bottom: 10px;
  }
  .related-posts-body {
    overflow-wrap: break-word;
    word-wrap: break-word;
  }
    /* ul */
    .related-posts-list-items {
      display: flex;
      flex-wrap: wrap;
      list-style: none;
      padding: 0;
      padding-bottom: 6px;
    }
    /* li */
    .related-posts-item {
      flex-shrink: 0;
      min-width: 0;
      width: 250px;
      overflow-wrap: break-word;
      word-wrap: break-word;
      text-align: center;
      transition: .3s box-shadow cubic-bezier(.4,0,.2,1);
      margin-top: 6px;
      margin-left: 6px;
    }
    .related-posts-item:hover {
      opacity: .8;
      box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2)
    }
    @media (max-width: 768px) {
      .related-posts-item {
        max-width: 250px;
        width: 100%;
        margin-left: 0;
      }
    }
    .related-posts-item-link {
      text-decoration: none;
      display: block;
      width: 100%;
      height: 100%;
    }
    .related-posts-item-text {
      text-align: left;
      padding: 0 10px;
      color: #444444;
    }
    .related-posts-item-date {
      text-align: end;
      color: #777777;
      font-size: 12px;
    }
</style>
<script>
  /**
   * 設定 ここから
   */
  // 関連記事の最大表示数
  const maxRelatedPosts = 6;
  // ラベル要素を取得するためのCSSセレクター(テーマによってはセレクターが違う場合があると思われ)
  const labelSelector = '.post-labels > a';
  // 関連記事のリストのタイトル
  const relatedPostsTitle = 'Related Posts';
  // 追加する要素のセレクター
  const targetSelector = '.post';
  // 実行するまでの遅延(1000は一秒)
  // (遅延する意味は無いけど非同期で実行されていることが確認できる)
  const delayTime = 1000;
  /**
   * 設定 ここまで
   */

  // 現在のページのホストからパスまで example.com/path/to/index.html
  const uri = `${location.hostname}${location.pathname}`;
  // 現在のページと同じURLかどうかを確認する正規表現
  const reCurrentPageURL = new RegExp(
    `https?:\\/\\/${uri.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')}`
  );
  function parseJson(json) {
    const items = [];
    for (const entry of json.feed.entry) {
      for (const link of entry.link) {
        if (!('rel' in link) || !('type' in link) || !('href' in link)) {
          continue;
        }
        if (link.rel != 'alternate' || link.type != 'text/html') continue;
        if (reCurrentPageURL.test(link.href)) continue;

        const item = {
          href: link.href,
          title: entry.title?.$t,
          summary: entry.summary?.$t,
          published: entry.published?.$t,
          updated: entry.updated?.$t,
          thumbnail:
            'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPoAAACWBAMAAAAF9IgvAAAAElBMVEXLy8v+/v7Jycnv7+/W1tbh4eG0JFuyAAAFfElEQVR42u1ZTWOrOAx0HHqv1sldK8i9Ds0dSHrP5///K29kCCENDfS9ffSwnlMhkLGk0chxjYmIiIiIiIiIiIiIiIiIiIiIiIiIiIiI+FFYy/xwj6ejZ/l0A9d2uvAfYw8r+CmITBW8PDArN09WdmuH1/S3kCS7R/BUwZf0iKX9SXbHj30gI9cj31m3fWB3/exmdBd+p1tLyj/jC/bRUvwW+5GlAyNZf+b/e+qGHa/Ylt3wV7H/FdUdRfhWK/4i9s/15BF1r6N5zr63NrlUHdGOin1MhsewH23iadOZrn3sFp20G9FZd8+NY9+Sq27zPgF7bb4rOuLaHhZYTXJAJ1ZF/dB2yaZ0GIVY5RzvgjL1FbN65IcnTSduKPIlP+tAZT/Qm9zYTcs+p1eVhF+IDa7kFk3AJZ6YYWkgl4Mv9PtLWuPS2jR4RsGSNr4pQ7EfqOK+2FP9i62yJ0RqBEX4QA6OZUavys6eKrAKIgiJ9+RORO9WVjX7xrJ9zu7p9kQndo0D6UTmBWl4l2xLb/UHYOcZLbEn40yfAb1OB8O4dEawfuE57fXZ3VN9hthdl70bO60zY8AOQ67YZiBs2UulMDInKlhf8g7dyim94eqAe6m2srqXDMQ+x8NfxA66ELtHoNaA9cqOzGNBgvJTERZ6ctA7MlIlbFZng9iPtX8M1Z39W9YfOxSBkkJsqj/Bw3VvHILqoDMNk5TjX3rBF6kejUGWjKouxM5PN2nKnmX7vrqDfU5rqA5x00L3mrMQp6pOuHT0ChH6nAp0ZelWEEWCrIitzSKtYzfMA+z2uqtGfNjet26TkvELrkLs/+g0SFt2ZN6VTooVnahIwgppof3nMouZgR5c0V4Gh33IvC0aj5cNUntlR90rMKDjruzzG7uduRnBU2iLe9b618w71ZvTOaVen9JZN2nD7FdtMIT6LqYTe5VqQ/axS0mwQtaMgz2DX6HkYjTz2eVyOV7dZphdGltneAXdJixiNwktRDWv7Nxl55nL/CtWtoIwM3ygmQrsBnqEBkazo9pti6F6ndgL613DrtmZ3bHjA0PrFPlH+xl+0Z2Csm/xLWt43SbPT5cRmedG5Ifg0V3V4Xsb1Ukw/lvdoQhN+zFVH9ImQPbrume7D9ju1W2G2TM611NFse+yQ7rvvbFDdSgtaq19gAydTjmKFJahMlhDQns7+IMs1L2kpSbe37Pb0GB+2bJzh93MHGcOXgjVgU2hotd1VVCLZv4oo3ZWGGAwMdtMpbvYLR8celkwXDHuytppm9jR5bBA1XzzptNBDPeQF1rbxm3skNNKWc9h38POOinBfHD6g8+7O9XBZvfIcIVBob29Bd9MJ5vMw4qOw0cRYF8F2uqFHtlBluVYmejWF6pe8K3fg9er3xT6N0q8gvGvUHydA7ZVHfPzvQ1CB7/zPeyoB/YNC4HA3HnnIabgC7XmEdaOQ1uG2WfDKMJzxVZHf0obuM7lLM/Zzw1tH3uBN1O3wCbCh59Y9RdxU/dgEyk2PPTK2rV+Kc3vwr3h687KPq/76dMPuZYdAevGIcN8MybU5a0ZWJpZ7CqDTSFBGYLGeEJBwjKxnRKd73/GDsOG1JLqvNdue8nzc+NK8nGWIvvgcMCS7UxyCWLkFbZRNjnlG2wqJbuc68z/Jns4wMHkqM8SxLTGhVs6Q7GDxkxm3VDpYMZSkAqdr7pY3Ar7ebH2d9nvvWrkkYJtV2llaML3sS95qoObki73hwf1lLFTsR/lDroll+lid961hyb4y3t13cli7zs14p88s/reydMf4ON0yTcd5Cd1lalOibkxhTvhTXgezQ/skx6Hy6d/FrCZkn5Sth87Do+IiIiIiIiIiIiIiIiIiIiIiIiIiIiI+H/iF/5oF83c0tPeAAAAAElFTkSuQmCC',
        };

        if ('media$thumbnail' in entry) {
          item.thumbnail = entry.media$thumbnail.url.replace(
            /([=/])s72-[^/&]+/,
            '$1w250-h150-n'
          );
        }
        items.push(item);
        break;
      }
    }
    return items;
  }

  function getDateFormattedString(dateString) {
    const date = new Date(dateString);
    const yyyy = date.getFullYear();
    const mm = String(date.getMonth() + 1).padStart(2, '0');
    const dd = String(date.getDate()).padStart(2, '0');
    return `${yyyy}-${mm}-${dd}`;
  }

  function createRelatedPostsListItem(item) {
    /* item {
      href: string;
      title: string;
      summary: string;
      published: string;
      updated: string;
      thumbnail: string;
    } */
    const li = document.createElement('li');
    li.className = 'related-posts-item';

    const link = document.createElement('a');
    link.className = 'related-posts-item-link';
    link.href = item.href;

    const img = document.createElement('img');
    img.className = 'related-posts-item-image';
    img.src = item.thumbnail;
    img.alt = item.title;
    img.title = item.title;
    img.width = '250';
    img.height = '150';

    const div = document.createElement('div');
    div.className = 'related-posts-item-text';

    const title = document.createElement('p');
    title.className = 'related-posts-item-title';
    title.innerText = item.title;
    div.appendChild(title);

    const date = document.createElement('p');
    date.className = 'related-posts-item-date';
    date.innerText = getDateFormattedString(
      item.updated ? item.updated : item.published
    );
    div.appendChild(date);

    link.appendChild(img);
    link.appendChild(div);
    li.appendChild(link);
    return li;
  }

  function createRelatedPosts(items) {
    const divRelatedPosts = document.createElement('div');
    divRelatedPosts.className = 'related-posts';

    const divTitle = document.createElement('div');
    divTitle.className = 'related-posts-title';
    divTitle.innerText = relatedPostsTitle;

    const divBody = document.createElement('div');
    divBody.className = 'related-posts-body';

    const ul = document.createElement('ul');
    ul.className = 'related-posts-list-items';

    items.forEach((item) => {
      const li = createRelatedPostsListItem(item);
      ul.appendChild(li);
    });

    divRelatedPosts.appendChild(divTitle);
    divRelatedPosts.appendChild(divBody);
    divBody.appendChild(ul);
    return divRelatedPosts;
  }

  function responsesToJson(responses) {
    return Promise.all(responses.map((response) => response.json()));
  }

  function failure(error) {
    console.log(`${error.message}`);
  }

  // 引数の data は JSON の配列
  function success(data) {
    let items = [];
    for (const json of data) {
      const item = parseJson(json);
      // マージして重複を除く
      items = [...items, ...item].filter((item, index, array) => {
        return array.findIndex((i) => i.href === item.href) === index;
      });
    }
    if (items.length == 0) return;
    const relatedPosts = createRelatedPosts(
      // slice の範囲指定は配列の要素の個数よりも最大数(maxRelatedPosts)が大きくてもエラーにはならない
      items.sort(() => Math.random() - 0.5).slice(0, maxRelatedPosts)
    );
    const targetElement = document.querySelector(targetSelector);
    if (!targetElement) return;
    targetElement.appendChild(relatedPosts);
  }

  // ページのラベルを取得
  function getLabels() {
    const labels = [];
    const re = /\/search\/label\/([^\/]+)/;
    const list = document.querySelectorAll(labelSelector);
    for (const a of list) {
      const m = re.exec(a.href);
      if (!m) continue;
      labels.push(m[1]);
    }
    return labels;
  }

  function handleEvent() {
    new Promise((resolve, reject) => {
      setTimeout(() => {
        const labels = getLabels();
        if (!labels.length) return;

        const hostname = location.hostname;
        const maxResults = 30;
        const promises = [];
        // 以下のドキュメントには "|" で OR、"," で AND と書かれている。
        // https://developers.google.com/gdata/docs/2.0/reference?hl=ja#Queries
        // "label1|label2" のようにすると、それぞれのラベルを持つ記事が取得できるはずだけど、
        // 機能しなかったのでループを回してラベルごとにリクエストを送る必要がある。
        for (const label of labels) {
          const rawUrl = `https://${hostname}/feeds/posts/default?alt=json&category=${encodeURIComponent(label)}&max-results=${maxResults}`;
          try {
            const promise = fetch(rawUrl);
            promises.push(promise);
          } catch (error) {
            console.error(error.message);
          }
        }

        Promise.all(promises)
          .then(responsesToJson)
          .then(success)
          .catch(failure);

        resolve(null);
      }, delayTime);
    });
  }

  addEventListener('DOMContentLoaded', handleEvent);
</script>


HTMLを編集する場合は テーマ から HTML の編集 を押して、body 要素の一番下にコピペ保存して下さい。 ただし、記事のページのみで関連記事を表示するのが普通なので、それように以下のように条件分岐のXMLタグが必要になる。

<b:if cond='data:blog.pageType == &quot;item&quot;'>
  <!-- ここにコードをコピペする -->
</b:if>



設定

表示する関連記事の最大数を指定します。3の倍数で指定すると綺麗に表示されると思います。

  const maxRelatedPosts = 6;


関連記事とは言っても同じラベル/カテゴリを持つ記事をランダムに取得しているだけです。 なので記事に付けてあるラベルが必要で、本コードはページ内のHTMLからラベル要素を取得しています。 そのためのセレクターを以下の定数に指定します。 これはテーマによって異なると思うので、使用する人は各自で確認して指定して下さい。 (Emporioを使っていて、ラベルを表示している人はこのままで良いはず)

  const labelSelector = '.post-labels > a';


関連記事の上の見出しとなる文字列を指定します。 ここでは Related Posts にしていますが 関連記事 が一般的かなと思います。

  const relatedPostsTitle = 'Related Posts';


関連記事のリストを追加する要素のセレクターを指定します。 Emporioテーマの場合だと .post を指定すると本文の末尾とコメントの間になると思います。

  const targetSelector = '.post';


スクリプトを実行するまでの遅延時間(ミリ秒)を指定します。 0 だと閲覧者の端末のスペックによっては、ページの読み込み時にリソースを食い合うかも知れないので、適当に 1000 ミリ秒(1秒)にしています。

  const delayTime = 1000;

この他に非同期実行のテストとして 5000 (5秒)位に設定して、ページをリロードし急いで関連記事が表示されるところへスクロールしてみて下さい。 まだ関連記事は表示されていないけどページの読み込みは終わるはずで、その後5秒経ってから関連記事が表示されると思います。 つまりページの読み込みに影響を与えずに処理が実行されることが確認できます。



コメント