【Blogger】関連記事を生成・出力表示するプログラム

公開
当サイトはアフィリエイト広告を使用しています

Blogger



関連記事を生成・出力表示するプログラムを書いてみました。

先行事例と比べてとくに新しい手法や機能はありませんが、自分なりに理解できる形、構成にしてみました。

関連記事はサムネイルとタイトルがランダムに表示されます。

JavaScriptのコードがコメントなしで250行くらい(コメントありだと400行くらい)になってしまい、効率的なコードとはほど遠いものになった印象はありますが、理解は深まりました。

とりあえずは動いているようなので公開します。なにぶん素人が作成したものなので勘違い等、多分にあると思います。

もし導入される場合は必ずバックアップをとってから作業してください。

概要

関連記事を生成するためのデータはラベルをもとにフィードから取得しました。

大きく分けると2個のプログラムで構成されています。

便宜的に「データタグの値を取得するプログラム」と「関連記事を出力するプログラム」とします。

関連記事を出力するプログラムは</body>の上あたりに設置できます。

実験的にですが任意のページ位置でプログラムを実行できるようにIntersectionObserverで交差を監視する機能を追加する方法を、記事後半に記載しています。

設置場所

プログラムを以下の2か所に設置します。

  • 「データタグの値を取得するプログラム」はブログウィジェト内に設置
  • 「関連記事を出力するプログラム」は</body>の上あたりに設置

「データタグの値を取得するプログラム」はブログウィジェト内の関連記事を出力したい場所に設置します。(<data:post.body/>の下が一般的でしょうか?)

設置場所の例

<data:post.body/>

......

<!-- データタグの値を取得するプログラム -->
<b:if cond='data:view.isPost and data:post.labels.notEmpty'>
<div id="related-post-wrapper"/> <!-- 出力先 -->
<script>
データタグの値を取得するJavaScript
</script>
</b:if>
<!-- End データタグの値を取得するプログラム -->

......

<!-- 関連記事を出力するプログラム -->
<b:if cond='data:view.isPost'>
<script>
関連記事を生成, 出力するJavaScript
</script>
</b:if>
<!-- End 関連記事を出力するプログラム -->

......

</body>

データタグの値を取得するプログラム

HTMLで出力先を指定、JavaScriptでデータタグの値を取得します。

Bloggerの独自タグ

<b:if cond='data:view.isPost and data:post.labels.notEmpty'>

で投稿かつラベルがある場合にのみ実行するようにしました。

<div id="related-post-container"/>が出力先です。

クリックすると開きます
<!-- データタグの値を取得するプログラム 関連記事の出力で使用 -->
<b:if cond='data:view.isPost and data:post.labels.notEmpty'>

<div id="related-post-wrapper"/> <!-- 出力先 -->

<script>
/**
 * データタグの値を取得する関数
 * @return {string | number}
 */
const getTagData = (() => {
  /**
   * ラベル名が格納される
   * @type {Array}
   */
  let labels = [];
  <b:loop values="data:post.labels" var="label">
  /** ラベル名を取得してlabelsに格納 */
  labels.push("<data:label.name/>");
  </b:loop>

  /**
   * いま開いているページのポストID
   * @type {number}
   */
  const currentPostId = "<data:post.id/>";

  /**
   * 記事のラベル数
   * @type {number}
   */
  const labelNumber = "<data:post.labels.length/>";
  return { labels, currentPostId, labelNumber };
})();
</script>
</b:if>
<!-- End データタグの値を取得するプログラム 関連記事の出力で使用 -->

関連記事を出力するプログラム

Bloggerの独自タグ

<b:if cond='data:view.isPost'>

で投稿の場合のみ実行。

(データタグdata:post.labels.notEmptyでラベルがある時だけ実行したかったのですが残念ながら</body>の上だと無効でした)

data:posts[i].labels (Blog|FeaturedPost|PopularPosts) - Blogger Data Documentation - Blogger Code PE

プログラム前半部分に設定項目を設けました。

クリックすると開きます
<!-- 関連記事を出力するプログラム -->
<b:if cond='data:view.isPost'>
<script>
/*! Copyright:2023 sutajp | Released under the MIT license | https://sutasutashiki.blogspot.com/p/mit-license.html */

/** 関連記事を生成, 出力するプログラム */
//<![CDATA[
async function createRelatedPost() {
  /**
   * 出力する最大値
   * @type {number}
   */
  const outputMaxValue = 6;

  /**
   * ラベル1つあたりのフィードデータ取得数
   * @type {number}
   */
  const queryMaxValue = "10";

  /**
   * 出力先
   * @type {string} div id="related-post-wrapper"
   */
  const outputElement = document.getElementById("related-post-wrapper");

  /**
   * 関連記事がない場合のメッセージ
   * @type {string}
   */
  const noRelatedPostText = "関連する記事はありません";

  /** サムネイル設定
   * @type { {[key: string]: number | string} }
   */
  const thumbnailSetting = {
    /**
     * サムネイルの幅
     * @type {number}
     */
    thumbnailWidth: 160,

    /**
     * サムネイルの高さ
     * @type {number}
     */
    thumbnailHeight: 90,

    /**
     * サムネイルの代替テキスト
     * @type {string}
     */
    thumbnailAlt: "関連記事サムネイル",

    /**
     * 記事に画像がない場合の代替画像
     * @type {string}
     */
    noImage:
      "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhmX1PIvH_DtYrO1nQ3TrIBjFaN-Dnmf_GUScL6INAhmm2z1CyqlAVOjq0eIgjPSWjsxJfixfVOSGIJIsZjA93MSyo0FtqNb3wrELJJa8-UxL9CRUsvRIBRm0wEoA8quYM3Cz54GO-mXuP21p5_UD1E6mGMbvmAOiNZ6s_GH4_ypjOSGYwsY9RqFhMgHQ/w160-h90-p/NoImage-300_185_compressed.png",

    /**
     * サムネイルのパラメーターを置き換え
     * @type {string}
     */
    parameter1: "w160-h90-p",
    /** サムネイルのパラメーターを置き換え */
    parameter2: "/w160-h90-p/",
    /** Youtubeのサムネイル用 */
    parameter3: "mqdefault.jpg",
  };

  /** 全角記号変換用の正規表現 */
  const decimalNumber = /&#([0-9]+);/g;

  /** 予約文字(RFC 2396)の一部と#変換用の正規表現 */
  const reservedCharacter = /;|\/|\?|&|\+|#/g;

  /**
   * 変更不可: フィードで取得した関連記事のデータが入る
   * @type {Array} [{title: string, url: string, thumbnail: string}]
   */
  let feedObjects = [];

/** getTagData関数で取得したラベル名をもとに関連記事を生成 */
  for (const labelName of getTagData.labels) {
    /** ラベル名 */
    let encodedLabel = labelName;

    /**
     * ラベル名に全角記号が含まれていたら
     *     String.fromCharCode()で文字列に変換
     */
    if (decimalNumber.test(labelName)) {
      encodedLabel = labelName.replace(decimalNumber, (match, p1) => {
        return String.fromCharCode(p1);
      });
    }

    /**
     * ラベル名に予約文字が含まれていたら
     *     encodeURIComponent()でURLエンコード
     */
    if (reservedCharacter.test(encodedLabel)) {
      encodedLabel = encodedLabel.replace(reservedCharacter, (match) => {
        return encodeURIComponent(match);
      });
    } else {
      encodedLabel = encodedLabel;
    }
    /** フィードデータを取得するURL */
    const feedUrl =
      "/feeds/posts/summary/-/" +
      encodedLabel +
      "?alt=json&max-results=" +
      queryMaxValue;
    const response = await fetch(feedUrl);
    const json = await response.json();
    /**
     * いま開いているページのポストID
     *     getTagData関数から取得
     * @type {number}
     */
    const currentPostId = getTagData.currentPostId;

    for (const jsonFeedEntry of json.feed.entry) {
      /**
       * 今開いているページかどうか判定用データ
       *     jsonFeedEntry.id.$tで文字列を取得
       *     例 tag:blogger.com,1999:blog-1234567890123456789.post-0149374603246402764
       *     取得した文字列から正規表現でポストIDを取得
       * @type {Array} ["0149374603246402764"]
       */
      const feedPostId = jsonFeedEntry.id.$t.match(/[0-9]+$/);

      /**
       * サムネイルのurl
       * @type {string}
       */
      let thumbnailUrl;

      /**
       * 今開いているページのポストIDと判定用IDが違っていたら処理を実行
       *     (今開いているページのデータは取得しない)
       *     判定用IDはfeedPostIdの0番目の要素を使用
       */
      if (currentPostId != feedPostId[0]) {
        /** サムネイルがある場合 */
        if ("media$thumbnail" in jsonFeedEntry) {
          /**
           * サムネイルのurl
           * @type {string}
           */
          thumbnailUrl = jsonFeedEntry.media$thumbnail.url;

          /**
           * サムネイルのパラメーター
           * @enum {string} 正規表現
           */
          const thumbnailParameter = {
            parameter1: /s72-.*$/,
            parameter2: /\/s72-.*\//,
            parameter3: /default.jpg$/,
          };

          /** サムネイルのパラメーターを置き換える */
          if (thumbnailParameter.parameter1.test(thumbnailUrl)) {
            thumbnailUrl = thumbnailUrl.replace(
              thumbnailParameter.parameter1,
              thumbnailSetting.parameter1
            );
          } else if (thumbnailParameter.parameter2.test(thumbnailUrl)) {
            thumbnailUrl = thumbnailUrl.replace(
              thumbnailParameter.parameter2,
              thumbnailSetting.parameter2
            );
          } else if (thumbnailParameter.parameter3.test(thumbnailUrl)) {
            thumbnailUrl = thumbnailUrl.replace(
              thumbnailParameter.parameter3,
              thumbnailSetting.parameter3
            );
          }
        } else {
          /** サムネイルがない場合の代替画像 */
          thumbnailUrl = thumbnailSetting.noImage;
        }

        for (const jsonFeedEntryLink of jsonFeedEntry.link) {
          if (jsonFeedEntryLink.rel == "alternate") {
            /**
             * feedObjectにタイトル、url、サムネイルを格納
             * @type {{title: string, url: string, thumbnail: string}}
             */
            let feedObject = {};
            feedObject.title = jsonFeedEntry.title.$t;
            feedObject.url = jsonFeedEntryLink.href;
            feedObject.thumbnail = thumbnailUrl;

            /** feedObjectをfeedObjectsに格納 */
            feedObjects.push(feedObject);
          }
        }
      }
    }
  }

  /** ラベルあり, 関連記事なしの場合 */
  if (feedObjects.length == 0) {
    noRelatedPost();
    return;
  }
  /**
   * 記事のラベル数
   *     getTagData関数から取得
   * @type {number}
   */
  let labelNumber = getTagData.labelNumber;
  /** ラベルが1個の場合 */
  if (labelNumber == 1) {
    /** 関連記事のデータが出力する最大値よりも多ければrandomize関数を実行 */
    if (feedObjects.length > outputMaxValue) {
      randomize(feedObjects);
      return;
      /** 関連記事のデータが出力する最大値と同じか, 少なければoutput関数を実行 */
    } else if (feedObjects.length <= outputMaxValue) {
      output(feedObjects);
      return;
    }
  }
  /** ラベルが複数の場合, 重複処理(重複しているデータを削除)する関数を実行 */
  unique();

  /** 関連記事がない場合に実行する関数 */
  function noRelatedPost() {
    const noRelatedPost = document.createElement("p");
    noRelatedPost.className = "related-post-wrapper__no-related-post";
    noRelatedPost.textContent = noRelatedPostText;
    outputElement.append(noRelatedPost);
  }

  /** 重複処理(重複しているデータを削除)する関数 */
  function unique() {
    /**
     * 重複を削除したデータ
     * @type {Array} [{title: string, url: string, thumbnail: string}]
     */
    const uniqueObjects = Array.from(
      new Map(feedObjects.map((data) => [data.url, data])).values()
    );
    /**
     * 重複処理したデータ数が表示する最大値よりも多ければ
     *     randomize関数を実行, 同じか少なければoutput関数を実行
     */
    uniqueObjects.length > outputMaxValue
      ? randomize(uniqueObjects)
      : output(uniqueObjects);
  }

/**
 * データをランダムにして指定した数を取得する関数
 * @param {Array} uniqueFeed 
 */
  function randomize(uniqueFeed) {
    /**
     * データをランダムにして指定した数を取得
     * 新しい配列を生成
     * @type {Array} [{title: string, url: string, thumbnail: string}]
     */
    const randomizedObjects = [...Array(outputMaxValue)].map(
      () =>
        uniqueFeed.splice(
          Math.floor(Math.random() * uniqueFeed.length),
          1
        )[0]
    );

    /** output関数を実行 */
    output(randomizedObjects);
  }

  /**
   * 関連記事を出力する関数
   * @param {Array} outputObjects 重複処理したデータ,ランダムに取り出したデータ,処理が必要ないデータのいずれか
   */
  function output(outputObjects) {
    /** フラグメントを定義 */
    const fragment = document.createDocumentFragment();

    /** データを埋め込んでHTMLを作成 */
    for (const outputObject of outputObjects) {
      /** figue要素を生成 */
      const figue = document.createElement("figue");
      figue.className = "related-post-container";

      /** a要素を生成 */
      const anchor = document.createElement("a");
      anchor.className = "related-post-container__anchor";
      anchor.href = outputObject.url;

      /** img要素を生成 */
      const img = document.createElement("img");
      img.className = "related-post-container__img";
      img.src = outputObject.thumbnail;
      img.width = thumbnailSetting.thumbnailWidth;
      img.height = thumbnailSetting.thumbnailHeight;
      img.alt = thumbnailSetting.thumbnailAlt;

      /** figcaption要素を生成 */
      const figcaption = document.createElement("figcaption");
      figcaption.className = "related-post-container__title";
      figcaption.textContent = outputObject.title;

      figue.append(anchor);
      anchor.append(img, figcaption);
      fragment.append(figue);
    }
    /** HTMLを出力 */
    outputElement.append(fragment);
  }
}
//]]>
createRelatedPost();
</script>
</b:if>
<!-- End 関連記事を出力するプログラム -->

補足

出力するHTMLに<figue><figcaption>を使用しました。

<div>などに変更可能です。

/** figue要素を生成 */
const div = document.createElement("div");
div.className = "related-post-container";

/** figcaption要素を生成 */
const title = document.createElement("div");
title.className = "related-post-container__title";
title.textContent = outputObject.title;

CSS

CSSは一例です。

CSS:クリックすると開きます
.related-post-container {
  display: block;
  margin: 30px auto;
}

.related-post-container:hover {
  opacity: 0.8;
}

.related-post-container__anchor {
  display: flex;
}

.related-post-container__img {
  display: block;
  height: auto;
  object-fit: cover;
  flex-basis: 40%;
  aspect-ratio: 1.618 / 1;
  min-width: 40vw;
  width: 100%;
  border: 1px solid rgb(240, 240, 240);
  box-sizing: border-box;
}

/* タイトルが長い場合指定行以上を省略する
* https://coliss.com/articles/build-websites/operation/css/css-line-clamp-property.html
*/
.related-post-container__title {
  margin-left: 1rem;
  font-weight: bold;
  display: -webkit-box;
  -webkit-line-clamp: 5;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

@media (min-width: 768px) {
  #related-post-wrapper {
    display: flex;
    flex-wrap: wrap;
  }

  .related-post-container {
    margin: initial;
    flex-basis: 33.3%;
    padding: 0.5em 0.5em;
  }

  .related-post-container__anchor {
    display: block;
  }

  .related-post-container__img {
    max-height: none;
    min-height: auto;
    min-width: auto;
  }

  .related-post-container__title {
    margin-left: initial;
    margin-top: 0.5rem;
    font-size: 90%;
    -webkit-line-clamp: 3;
  }
}

IntersectionObserverで交差を監視

関連記事を出力するプログラムのJavaScript最後の行createRelatedPost();を下記と置き換えると、ページの任意の位置(オプション)でプログラムが実行します。

IntersectionObserverのrootMarginをわかりやすく説明

クリックすると開きます
/** IntersectionObserverを設定する関数 */
//<![CDATA[
const observeElement = (() => {
  /** コールバック関数 */
  const callback = (entries, observer) => {
    /** 配列の最初の要素 entries[0] */
    if (entries[0].isIntersecting) {
      /** 交差したらcreateRelatedPost関数を実行 */
      createRelatedPost();

      /** 第2引数のobserverのunobserve()メソッドで要素の監視を停止 */
      observer.unobserve(entries[0].target);
    }
  };

  /** オプション */
  const options = {
    /** rootMargin以外はデフォルト(省略した場合と同じ) */
    root: null,
    rootMargin: "1800px 0px",
    threshold: 0,
  };

  /** 引数にコールバック関数とオプションを指定してオブザーバーを生成 */
  const observer = new IntersectionObserver(callback, options);

  /** 監視対象の要素(関連記事の出力先)*/
  const targetElem = document.getElementById("related-post-wrapper");

  /** observe()に監視対象の要素を指定 */
  observer.observe(targetElem);
})();
//]]>

また、やや面倒なやり方ですが、createRelatedPost関数の引数にtargetElemを設定。出力先const outputElementtargetElemに変更。

observeElement関数のcreateRelatedPost();の引数にtargetElemを設定してもOKです。

async function createRelatedPost(targetElem) {
...
const outputElement = targetElem

}

const observeElement = (() => {
...
createRelatedPost(targetElem);

}

参考サイト

【徹底解説】Bloggerフィードの各種パラメータと使い方 | IB-Note

【Blogger】関連記事表示のお手軽プラグイン(改良版) | IB-Note

Bloggerの関連記事表示を強化する | バグ取りの日々



検索

カテゴリー

Random Picks

すたすた式

Enjoy!👍

QooQ