0
0

【Node.js】XMLサイトマップの自動生成~<lastmod>の時間にGitのコミット日時を反映させて~

Last updated at Posted at 2024-08-15

検索エンジンにWebサイト内のファイル情報を伝えるXMLサイトマップ。
Google検索セントラルによると、必須というわけではなさそうですが、SEO対策として作成しています。

今まではサイトのローンチ時や、新規ページ作成時にオンラインジェネレーターで作成していました。
そんな中、<lastmod> はページの更新日時を指定する必要があることを知りました。

ページを更新するたびにオンラインジェネレーターで作成するのは面倒すぎる…:tired_face:

というわけで、<lastmod> の時間にGitのコミット日時を反映したXMLサイトマップを自動生成できるようにしてみました。

仕様

XMLサイトマップに含めるファイルは任意のディレクトリ以下のファイルを対象とします。
<lastmod> にはGitの最終コミット日時が反映されます。

  • XMLサイトマップに含めるファイル
    • src ディレクトリ内の .html.pdf(デフォルト)
      • ディレクトリ名は変更可能(オプション)
      • 対象ファイルは変更可能(オプション)
      • 対象ファイルはgitのコミットログが取得可能(必須)
  • XMLサイトマップのファイル名
    • sitemap.xml(デフォルト)
      • ファイル名は変更可能(オプション)
  • XMLサイトマップの出力ディレクトリ
    • dist ディレクトリに生成(デフォルト)
      • ディレクトリ名は変更可能(オプション)
      • ディレクトリが存在しない場合は自動で生成

XMLサイトマップの構造

作成されるXMLサイトマップの構造は以下となります。

Googleは、<priority><changefreq> の値を無視するため、各 <url> には <loc><lastmod> だけ指定しています。

sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com/</loc>
    <lastmod>2024-02-03T00:00:00+09:00</lastmod>
  </url>
</urlset>

ファイル構造

デフォルト設定では以下のような構造になります。

project
├── .git
├── dist/
│   └── sitemap.xml
├── node_modules
├── src/
│   ├── index.html
│   ├── hoge
│   │   └── index.html
│   ├── fuga
│   │   └── index.html
│   └── piyo
│       └── index.html
├── _xml-sitemap-generator.bat
├── xml-sitemap-generator.js
├── package.json
└── package-lock.json

準備

package.json の作成

package.json
{
  "name": "xml-sitemap-generator",
  "version": "1.0.0",
  "license": "UNLICENSED",
  "private": true,
  "scripts": {
    "xml-sitemap-generator": "node ./xml-sitemap-generator.js"
  },
  "type": "module"
}

node_module のインストール

必要な node_module は以下となります。

モジュール名 役割
ansi-colors ログメッセージに色をつける
fancy-log 時刻付きのログを表示
globule ファイルのパスを取得
child_process ファイルのコミット日時を取得
date-fns 日時をISO 8601形式に変換
util 非同期処理
npm i ansi-colors fancy-log globule child_process date-fns util -D

npm-scripts の作成

xml-sitemap-generator.js
import c from 'ansi-colors'
import log from 'fancy-log'
import fs from 'fs/promises'
import globule from 'globule'
import { exec } from 'child_process'
import { format } from 'date-fns'
import { promisify } from 'util'

const execAsync = promisify(exec)

class XmlSitemapGenerator {
  #defaults = {
    siteUrl: null,
    srcDir: 'src',
    distDir: 'dist',
    src: ['/**/*.{html,shtml,pdf}'],
    fileName: 'sitemap.xml',
    mappings: []
  }

  #options

  constructor(options = {}) {
    this.#options = { ...this.#defaults, ...options }
    try {
      if (!this.#options.siteUrl) {
        throw new Error()
      }
      this.#init()
    } catch (error) {
      log(
        c.red('ERROR! ') +
          `'` +
          c.yellow('siteUrl') +
          `'` +
          ' option is required but not provided.'
      )
    }
  }

  async #init() {
    try {
      const fileList = await this.#getFileList()
      const sitemapContent = this.#generateSitemapContent(fileList)
      await this.#writeSitemapFile(sitemapContent)
    } catch (error) {
      log(c.red('ERROR! ') + error.message)
    }
  }

  /**
   * 対象ファイルのパスとコミット日時を持つオブジェクトの配列を返す
   * @return { array } 対象ファイルの配列
   */
  async #getFileList() {
    this.#options.src = this.#options.src.map(
      (src) => `${this.#options.srcDir}${src}`
    )
    const files = globule.find({ src: this.#options.src })
    const filePromises = files.map(async (file) => {
      try {
        const { stdout } = await execAsync(`git log -1 --format=%cI "${file}"`)
        const gitFromTime = stdout.trim()
        const formattedTime = format(
          new Date(gitFromTime),
          "yyyy-MM-dd'T'HH:mm:ssXXX"
        )
        return {
          file,
          lastmod: formattedTime
        }
      } catch (error) {
        log(`No Git commit found for file: '` + c.yellow(file) + `'`)
        return null
      }
    })
    const fileList = await Promise.all(filePromises)
    return fileList.filter(Boolean)
  }

  /**
   * XMLサイトマップのコンテンツを返す
   * @param { array } fileList 対象ファイルの配列
   * @return { string } XMLサイトマップのコンテンツ
   */
  #generateSitemapContent(fileList) {
    const sitemapHeader = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`
    const sitemapFooter = `</urlset>`
    const sitemapBody = this.#getSitemapBody(fileList)
    return sitemapHeader + sitemapBody + sitemapFooter
  }

  /**
   * すべての<url>要素の文字列を返す
   * @param { array } fileList 対象ファイルの配列
   * @return { string } すべての<url>要素の文字列
   */
  #getSitemapBody(fileList) {
    return fileList
      .map(({ file, lastmod }) => this.#getLocAndLastmod(file, lastmod))
      .sort((a, b) => a.loc.localeCompare(b.loc))
      .map(({ loc, lastmod }) => this.#formatUrlElement(loc, lastmod))
      .join('')
  }

  /**
   * <loc>と<lastmod>の値を持つオブジェクトを返す
   * @param { string } file 対象ファイルのパス
   * @param { string } lastmod 対象ファイルのコミット日時
   * @return { object } <loc>と<lastmod>の値を持つオブジェクト
   */
  #getLocAndLastmod(file, lastmod) {
    let loc = file.replace(this.#options.srcDir, this.#options.siteUrl)
    this.#options.mappings.forEach((mapping) => {
      if (loc.includes(mapping.fromPath)) {
        loc = loc.replace(mapping.fromPath, mapping.toPath)
      }
    })
    // <loc>の末尾にindex.htmlとindex.phpが含まれていたら削除
    if (loc.endsWith('index.html') || loc.endsWith('index.php')) {
      loc = loc.replace(/index\.(html|php)$/, '')
    }
    return { loc, lastmod }
  }

  /**
   * <url>要素の文字列を返す
   * @param { string } loc 対象ファイルのパス
   * @param { string } lastmod 対象ファイルのコミット日時
   * @return { string } <url>要素の文字列
   */
  #formatUrlElement(loc, lastmod) {
    return `  <url>\n    <loc>${loc}</loc>\n    <lastmod>${lastmod}</lastmod>\n  </url>\n`
  }

  /**
   * XMLサイトマップの作成
   */
  async #writeSitemapFile(content) {
    try {
      // ディレクトリが存在しない場合は作成する
      await fs.mkdir(this.#options.distDir, { recursive: true })
      await fs.writeFile(
        `${this.#options.distDir}/${this.#options.fileName}`,
        content
      )
      const urlCount = (content.match(/<url>/g) || []).length
      log(
        `Created '` +
          c.green(`${this.#options.distDir}/${this.#options.fileName}`) +
          `' in ` +
          c.green(urlCount) +
          ` <url> elements.`
      )
    } catch (error) {
      log.error(
        c.red('ERROR! ') +
          `Failed to create '` +
          c.yellow(`${this.#options.distDir}/${this.#options.fileName}`) +
          ` '${error}`
      )
    }
  }
}
const xmlSitemapGenerator = new XmlSitemapGenerator({
  siteUrl: 'https://example.com',
})

オプション

インスタンス作成時、以下のオプション設定が可能です。

siteUrl は必須オプションとなります。

オプション 内容 デフォルト
siteUrl サイトのURL string null
srcDir XMLサイトマップに含めるファイルの格納ディレクトリ名 string 'src'
distDir XMLサイトマップの出力ディレクトリ名 string 'dist'
src XMLサイトマップに含めるファイルのglobパターン
srcDir以下を指定
array srcDir内のすべてのHTMLとPDF
fileName XMLサイトマップのファイル名 string 'sitemap.xml'
mappings 指定したファイルを任意のURLにマッピングさせます array []
xml-sitemap-generator.js
const xmlSitemapGenerator = new XmlSitemapGenerator({
  // サイトのURL
  siteUrl: 'https://example.com',
  // XMLサイトマップに含めるファイルの格納ディレクトリ変更
  srcDir: 'hoge',
  // XMLサイトマップの出力ディレクトリ変更
  distDir: 'hoge',
  // XMLサイトマップに含めるファイルを変更(srcDir以下のパスを指定)
  src: [
    '/!(_wp)/**/*.html',
    '/assets/**/*.pdf',
    '/_wp/wp-content/themes/original-theme/front-page.php',
    '/_wp/wp-content/themes/original-theme/index.php'
  ],
  // 指定したファイルを任意のURLにマッピング
  mappings: [
    {
      fromPath: '/_wp/wp-content/themes/original-theme/front-page.php',
      toPath: '/'
    },
    {
      fromPath: '/_wp/wp-content/themes/original-theme/index.php',
      toPath: '/news/'
    }
  ]
})

バッチファイル の作成(Windows)

Windows のみとなります。
npm-scripts の実行を簡単にするためにバッチファイルを作成します。

_xml-sitemap-generator.bat
@echo off
PowerShell -command npm run xml-sitemap-generator
pause

npm-scripts の実行

Windows

バッチファイル( _xml-sitemap-generator.bat )をダブルクリックでOK:ok_hand:

Mac

ターミナルで xml-sitemap-generator.js ファイルのあるディレクトリ移動し、以下コマンドを実行します。

npm run xml-sitemap-generator
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0