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

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

https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview?hl=ja

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

https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap?hl=ja#additional-notes-about-xml-sitemaps

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

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

## 仕様

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

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

### XMLサイトマップの構造

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

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

https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap?hl=ja#additional-notes-about-xml-sitemaps

```xml: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 の作成

```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          | 非同期処理                |

```powershell
npm i ansi-colors fancy-log globule child_process date-fns util -D
```

### npm-scripts の作成

```js: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}\n`
      )
      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',
})
```

#### オプション

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

:::note warn
`siteUrl` は必須オプションとなります。
:::

| オプション             | 内容                           | 型      | デフォルト |
|----------------------|--------------------------------|---------|-------------|
| siteUrl              | サイトのURL  | string  | null |
| srcDir               | XMLサイトマップに含めるファイルの格納ディレクトリ名      | string  | 'src' |
| distDir              | XMLサイトマップの出力ディレクトリ名      | string  | 'dist' |
| src                  | XMLサイトマップに含めるファイルのglobパターン<br>`srcDir`以下を指定 | array | `srcDir`内のすべてのHTMLとPDF |
| fileName | XMLサイトマップのファイル名 | string | 'sitemap.xml' |
| mappings             | 指定したファイルを任意のURLにマッピングさせます  | array  | [] |

```js: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）

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

```powershell:_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` ファイルのあるディレクトリ移動し、以下コマンドを実行します。

```powershell
npm run xml-sitemap-generator
```