sounisi5011/sounisi5011.jp

ページの公開日時と最終更新日時の生成

Closed this issue · 8 comments

ページに、公開日時と最終更新日時を挿入する案を提案したい。

現状、各ページは一つのPugファイルから生成されているが、Pugファイルは他のファイルや生成したHTMLの結果を読み取る複雑な依存関係に基づいているため、Pugファイルの更新日時を読み取ってページの最終更新日時とする事は不適切。公開日時に関しては、そもそもUnix系のファイルシステムはファイルの作成日時を保持していない。

そこで、Gitの履歴情報と、Netlifyのビルド結果から、ページの公開日時と更新日時を取得する事を考えている。

ページの作成日時に関しては、Gitの履歴情報において、該当のPugファイルが追加された最初のコミットを取得する。そのコミット以降で、Netlifyにビルド結果が存在するものを探す。それが、ページが公開された瞬間だと判定できる。

ページの更新日時に関しては少々複雑になる。

まず、生成したHTMLとは内容が異なる最新のNetlifyのビルド結果が更新された瞬間となる。だが、Gitの履歴情報で該当のPugファイルが変更された以降のビルド結果を探す手法は、依存したファイルの更新を判定できないことや、Gitで変更されたコミット以降のビルド結果が存在しない場合のフォールバックなどもあるため、Gitの履歴情報を元にした絞り込み処理は行うべきではない。よって、git log HEAD --first-parentコマンドで取得した全てのコミットに対応するNetlifyのビルド結果を、最新のものから順にクロールする必要がある。

ただし、このやり方だと、ビルドの度に過剰なクロール処理が走ってしまう。特に、更新が殆ど無いトップページなどは、大量のページの更新が行われた場合、最後の更新を探すために大量のクロール処理が必要になってしまう。

そこで、ページの最終更新日時を、最も最新のビルド結果から取得する最適化処理を提案したい。ページの内容に変化が無く、かつ、Last-ModifiedヘッダやMicrodata等でページ毎に定義された最終更新日時が取得できた場合は、それをそのまま流用する案だ。同じ手法で公開日時も取得できれば、公開日時の取得処理もスキップすることが可能になる。

最後に、懸念されるいくつかの問題点を考えたい。

第一に、Last-ModifiedヘッダやMicrodata等の値が信用できないビルド結果が存在する可能性だ。この機構を開発し、運用する前の段階では、誤った形式でページの最終更新日時が記述されてしまう可能性が存在する。そのようなビルド結果を、適切に除外する手法を考える必要がある。

第二に、ページの内容が同一であることをどのように判定するか。判定には、現在生成中のページと、過去にビルドされたページを比較し、その結果から公開日時と最終更新日時を挿入しなければならない。だが、各ページには時間によって変化する情報が含まれている。それをどのように除外し、比較するかを考えなければならない。

第三に、Pugファイルがリネームされる場合の処理。これはアルゴリズムを考える事にもなる。ページやファイルを移動した場合、移動した事をどのように判定するかや、移動した場合にも日付情報を維持するべきかどうかなど、考えるべき事は多い。

例えば、キャラクター一覧のページを生成するPugファイルを考えたい。このファイルはsrc/pages/characters.pugに置かれているが、これはsrc/pages/characters/index.pugに移動しても同一のページを生成する。このリネームルールは、metalsmith-permalinksの機構に依存している。この二つのファイルを、どのように同一と判定するべきかを考えなければならない。

また、src/pages/characters.pugsrc/pages/キャラシ.pugに変更した場合を考えたい。ページのアドレスそのものを/characters/から/キャラシ/に変更するものだが、この場合、ページを移動したと考えるか、前のページを削除して新しく作ったと考えるべきか。リダイレクトの有無で判定するにしても、新しいビルドから辿る場合、変更前のページアドレスの取得が必須になる。そのアルゴリズムを構築できるかどうかが課題として浮かぶ。

そこで、ページの最終更新日時を、最も最新のビルド結果から取得する最適化処理を提案したい。ページの内容に変化が無く、かつ、Last-ModifiedヘッダやMicrodata等でページ毎に定義された最終更新日時が取得できた場合は、それをそのまま流用する案だ。同じ手法で公開日時も取得できれば、公開日時の取得処理もスキップすることが可能になる。

第一に、Last-ModifiedヘッダやMicrodata等の値が信用できないビルド結果が存在する可能性だ。この機構を開発し、運用する前の段階では、誤った形式でページの最終更新日時が記述されてしまう可能性が存在する。そのようなビルド結果を、適切に除外する手法を考える必要がある。

環境変数で最適化機構を切ることを可能にすれば良いかもしれない。最適化機構は一種のキャッシュ処理に該当するが、キャッシュの不具合を回避するために、キャッシュを切ってビルドする選択肢は可能にするべきだろう。環境変数は、本番ビルドの前にも変更することができるし、ローカルの開発環境でも修正が可能だ。

第二に、ページの内容が同一であることをどのように判定するか。判定には、現在生成中のページと、過去にビルドされたページを比較し、その結果から公開日時と最終更新日時を挿入しなければならない。だが、各ページには時間によって変化する情報が含まれている。それをどのように除外し、比較するかを考えなければならない。

Microdataでマークアップされたtime要素の内容とdatetime属性の値を空にするやり方が有効と考える。それ以外で、日付に依存する情報を含める場合があるとは考えられない。

第二に、ページの内容が同一であることをどのように判定するか。判定には、現在生成中のページと、過去にビルドされたページを比較し、その結果から公開日時と最終更新日時を挿入しなければならない。だが、各ページには時間によって変化する情報が含まれている。それをどのように除外し、比較するかを考えなければならない。

必ず判定は必要なのだから、先にビルド結果をクロールし、ビルド結果から取得した日付情報をセットして現在のビルドを行うのが有効だと考える。内容の変化が無い場合は、前の最終更新日時を流用することになるはずだから、その処理を省略することができる。

ページの作成日時に関しては、Gitの履歴情報において、該当のPugファイルが追加された最初のコミットを取得する。そのコミット以降で、Netlifyにビルド結果が存在するものを探す。それが、ページが公開された瞬間だと判定できる。

該当のPugファイルがリネームされたものだったりした場合などに、正しく履歴を取得できない可能性がある。リネームがGitに記録されていればgit log --followで追跡できるが、ファイルの修正内容次第ではリネームしたと判定されない。

おそらくURLは変化しないから、面倒だが、git log --first-parentで辿れる全てのNetlifyのビルド結果を取得して、該当のURLが最初に出現する瞬間を取得したほうが良いと考える。Gitの履歴情報でファイルが作成されたコミットを検出するのは愚策。

最も最新のビルド結果にページの生成日時が含まれていれば、それを使用すればいい。その最適化機構が働くならば、この負荷が大きいクロール処理は最初だけで済む。

ここまででのアルゴリズムまとめ

  1. git log --first-parentで、現在のブラウザから辿れる全てのコミットハッシュを取得。
  2. NetlifyのAPIで全てのデプロイを取得し、コミットハッシュと照らし合わせて、対象のビルド結果一覧のURLを取得する。
  3. 最も最近のビルド結果から順に、各ページのクロールを試みる。
    • ページが存在しなかった場合:
      直前のビルド結果、または、いま生成しようとしているページの生成時刻を、ページの公開日時と最終更新日時とする。このページのクロールは終了。
    • ページが存在する場合:
      ページの公開日時と最終更新日時の取得を試みる。
      • ページの公開日時と最終更新日時を取得出来た場合:
        その日付情報を元にして、現在のページを生成し、差分を比較する。
        • ページの内容が同一の場合:
          変更なし。生成した現在のページをそのまま公開する。このページのクロールは終了。
        • ページの内容が異なる場合:
          変更あり。最終更新日時を、直前のビルド結果、または、いま生成しようとしているページの生成時刻に設定して、現在のページを再生成する。このページのクロールは終了。
      • ページの公開日時と最終更新日時を取得できなかった場合:
        ページが存在しないビルド結果まで遡る。取得できなかったページは、現在のページとは確実に異なるはずなので、最終更新日時は現在時刻とする。

※環境変数による、最適化機構のオフは組み込んでいない。

ここまででのアルゴリズムまとめ

  1. git log --first-parentで、現在のブラウザから辿れる全てのコミットハッシュを取得。

  2. NetlifyのAPIで全てのデプロイを取得し、コミットハッシュと照らし合わせて、対象のビルド結果一覧のURLを取得する。

  3. 最も最近のビルド結果から順に、各ページのクロールを試みる。

    • ページが存在しなかった場合:
      直前のビルド結果、または、いま生成しようとしているページの生成時刻を、ページの公開日時と最終更新日時とする。このページのクロールは終了。

    • ページが存在する場合:
      ページの公開日時と最終更新日時の取得を試みる。

      • ページの公開日時と最終更新日時を取得出来た場合:
        その日付情報を元にして、現在のページを生成し、差分を比較する。

        • ページの内容が同一の場合:
          変更なし。生成した現在のページをそのまま公開する。このページのクロールは終了。
        • ページの内容が異なる場合:
          変更あり。最終更新日時を、直前のビルド結果、または、いま生成しようとしているページの生成時刻に設定して、現在のページを再生成する。このページのクロールは終了。
      • ページの公開日時と最終更新日時を取得できなかった場合:
        ページが存在しないビルド結果まで遡る。取得できなかったページは、現在のページとは確実に異なるはずなので、最終更新日時は現在時刻とする。

※環境変数による、最適化機構のオフは組み込んでいない。

処理コードのロジックはこんな感じかな?

/*
 * Netlifyのビルド結果にアクセスするトップURL。最新のものが0番目。
 * Gitの履歴情報とNetlifyのAPIから取得する。
 */
const netlifyDeployURLList = [
  'https://0123456789abcdef01234567--hoge-HUGA---1234.netlify.com',
  ...
];

/*
 * ページのURLパスの配列
 */
const pagePathList = [
  '/',
  '/characters/',
  ...
];

/*
 * Netlifyのビルド結果から公開日時と更新日時を取得できた場合、それを流用してクロールを終了するか否か。
 * falseの場合、公開日時と更新日時を正しく取得するまでクロールを続ける。
 */
const isUseDeployedDate = true;

/*
 * URLパスとページの生成日時・更新日時・クロールの終了状態を対応付けるマップ
 */
const nowDate = Date.now();
const dateMap = new Map(pagePathList.map(path => [
  path,
  {
    // 公開日時と更新日時は、現在時刻を初期値とする。
    // 変化が無いビルド結果や、既に存在するビルド結果を検出した場合は、古い方の日付で上書きしていく
    published: new Date(nowDate),
    modified: new Date(nowDate),
    crawlerStopped: false,
    // 更新日時が確定済の場合は、公開日時の確定のみが目的となるため、trueに設定
    publishedOnly: false,
  },
]));

for (const deployRootURL of netlifyDeployURLList) {
  /*
   * 各ページ毎にクロールする
   */
  for (const path of pagePathList) {
    const nowPageData = dateMap.get(path);

    /*
     * このページのクロールが完了していたら次へ進む
     */
    if (nowPageData.crawlerStopped) {
      continue;
    }

    /*
     * ページをクロールする
     */
    const pageURL = urlJoin(deployRootURL, path);
    const pageResponse = fetch(pageURL);

    /*
     * ページが存在しない場合は、直前のビルド結果、または、現在の時刻が、ページの公開日時。
     * ページの公開日時は初期値が直前のビルド結果のものなので、変更はしない。
     * クロールを終了
     */
    if (isNotFound(pageResponse)) {
      nowPageData.crawlerStopped = true;
      continue;
    }

    /*
     * ページが存在するため、公開日時をビルド結果のもので上書きする。
     */
    nowPageData.published = getPublishedDateByNetlifyDeployURL(deployRootURL);

    /*
     * 公開日時の探索モードになっている場合は、更新日時を確定する以降の処理は行わない
     */
    if (nowPageData.publishedOnly) {
      continue;
    }

    /*
     * ビルド結果の公開日時と更新日時の取得を試みる
     */
    const publishedPageDateData = getPageDate(pageResponse);
    if (publishedPageDateData) {
      /*
       * ビルド結果の公開日時と更新日時を取得出来た場合は、
       * ビルド結果の公開日時と更新日時を元に、現在のページを再生成し、内容が同一か検証する。
       */
      if (renderPageContent(path, publishedPageDateData) === pageResponse.contents) {
        /*
         * 生成したページの内容が同じ場合、変化なしと判定する。
         */
        if (isUseDeployedDate) {
          /*
           * isUseDeployedDateフラグが有効な場合、ページの公開日時と更新日時を、このビルド結果の情報で上書きする。
           * 公開日時と更新日時を取得できたため、クロールを終了する。
           */
          nowPageData.published = publishedPageDateData.published;
          if (!nowPageData.publishedOnly) {
            nowPageData.modified = publishedPageDateData.modified;
          }
          nowPageData.crawlerStopped = true;
        } else {
          /*
           * isUseDeployedDateフラグが無効な場合、ビルド結果の公開日時を更新日時に設定する。
           * ページの公開日時の取得と、更新日時を絞り込むため、クロール処理は継続。
           */
          nowPageData.modified = getPublishedDateByNetlifyDeployURL(deployRootURL);
        }
      } else {
        /*
         * 生成したページの内容が異なる場合、変化ありと判定する。
         * ページの更新日時は初期値が直前のビルド結果のものなので、変更はしない。
         * ページの公開日時を取得するため、クロール処理は継続。
         */
        nowPageData.publishedOnly = true;
      }
    } else {
      /*
       * ページの公開日時と更新日時を取得できなかった場合、取得できる形式で描画されているハズの現在の内容とはおそらく異なるため、
       * 更新日時は、直前のビルド結果のものとする。
       * ページの更新日時は初期値が直前のビルド結果のものなので、変更はしない。
       * ページの公開日時を取得するため、クロール処理は継続。
       */
      nowPageData.publishedOnly = true;
    }
  }

  /*
   * 全てのクロール処理が完了していたらループを抜ける
   */
  const crawlerAllStopped = [...dateMap.values()].every(nowPageData => nowPageData.crawlerStopped);
  if (crawlerAllStopped) {
    break;
  }
}
  1. git log --first-parentで、現在のブラウザから辿れる全てのコミットハッシュを取得。

  2. NetlifyのAPIで全てのデプロイを取得し、コミットハッシュと照らし合わせて、対象のビルド結果一覧のURLを取得する。

  3. 最も最近のビルド結果から順に、各ページのクロールを試みる。

    • ページが存在しなかった場合:
      直前のビルド結果、または、いま生成しようとしているページの生成時刻を、ページの公開日時と最終更新日時とする。このページのクロールは終了。

    • ページが存在する場合:
      ページの公開日時と最終更新日時の取得を試みる。

      • ページの公開日時と最終更新日時を取得出来た場合:
        その日付情報を元にして、現在のページを生成し、差分を比較する。

        • ページの内容が同一の場合:
          変更なし。生成した現在のページをそのまま公開する。このページのクロールは終了。
        • ページの内容が異なる場合:
          変更あり。最終更新日時を、直前のビルド結果、または、いま生成しようとしているページの生成時刻に設定して、現在のページを再生成する。このページのクロールは終了。
      • ページの公開日時と最終更新日時を取得できなかった場合:
        ページが存在しないビルド結果まで遡る。取得できなかったページは、現在のページとは確実に異なるはずなので、最終更新日時は現在時刻とする。

ページの公開日時と最終更新日時を取得する必要は無い気がする。代わりに、時刻情報で変化してしまう箇所を比較する時に無視するようにするのが適切だと思う。再ビルドの必要もなくなる。