Nuxt で定期的に変更される Sitemap ファイルの生成方法

## 初めに
現場で SEO 対策の業務を行なっているのですが、Nuxt で動的に変更されるような Sitemap の生成方法がなかなか難しかった（記事もあまり見つからなかった）ので、ハマったこととどう解決したのかなどを記事にまとめようと思います。

そもそも、なんで動的に変更される Sitemap ファイル生成が必要になったかですが、[Sitemap に記載する URL が 50,000 件を超える Sitemap はファイルを分割する必要があります。](https://developers.google.com/search/docs/advanced/sitemaps/build-sitemap?hl=ja#:~:text=1%20%E3%81%A4%E3%81%AE%E3%82%B5%E3%82%A4%E3%83%88%E3%83%9E%E3%83%83%E3%83%97%E3%81%AB%E5%AF%BE%E3%81%97%E3%81%A6%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E4%B8%8A%E9%99%90%EF%BC%88%E5%9C%A7%E7%B8%AE%E3%81%97%E3%81%A6%E3%81%84%E3%81%AA%E3%81%84%E7%8A%B6%E6%85%8B%E3%81%A7%2050%20MB%EF%BC%89%E3%80%81URL%20%E3%81%AE%E4%BB%B6%E6%95%B0%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E4%B8%8A%E9%99%90%EF%BC%8850%2C000%20%E4%BB%B6%EF%BC%89%E3%81%8C%E8%A8%AD%E3%81%91%E3%82%89%E3%82%8C%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%82)その分割ファイル数がいくつあるかが動的に変更されるためとなります。

## バージョン情報
今回の実装にあたり、利用するライブラリ情報は下記となります。

* Nuxt: 2系
* [node-cron](https://www.npmjs.com/package/node-cron): 3.0.0
* [sitemap](https://www.npmjs.com/package/sitemap): 7.1.1

## 処理フロー
最終的な今回の処理のフローは下記のようになります。

* nuxt server listen
  * 初めに各 Sitemap ファイルを生成
  * その後に cron を設定し、その cron 処理でも各 Sitemap ファイルを生成

* nuxt generate down
  * dist 配下に各 Sitemap ファイルを生成

よく Nuxt + Sitemap で調べるとよく [@nuxtjs/sitemap](https://www.npmjs.com/package/@nuxtjs/sitemap) ライブラリが出てきますが、こちら、この後も記載しますが、結論言うとやりたいことの実現が無理でした。。。

## 異変に気づくまでの実装
最初に用件を聞き、実装した際は `@nuxtjs/sitemap` ライブラリを用いて実装して感動してました。（顔文字で表現すると「ｲｪｲL(‘ω’)┘三└(‘ω’)」ｲｪｲ」こんな感じでした）
下記に記載するソースは、現場のソースを記載するのはまずいので、いろいろゴニョゴニョ変更（URL など）してますが、当時の実装はこのような感じで実装していました。

```
$ yarn add @nuxtjs/sitemap
```

```typescript:nuxt.config.ts
import axios from 'axios'
import logger from 'consola'
import { convertJapanDate } from './util/date'

export default {
  ...
  modules: [
    '@nuxtjs/sitemap'
  ],
  async sitemap() {
    const response = await axios.get<number[]>('http://localhost:8080/sitemaps/xml/ids').catch((err) => {
      logger.error(err)
      return null
    })
    if (!response) {
      return false
    }
    return {
      hostname: 'http://localhost:3000',
      sitemaps: response.data.map((id) => ({
        path: `/sitemaps/${id}.xml`,
        async routes() {
          const { data } = await axios.get<{ id: number; lastmod: string }[]>(
            `http://localhost:8080/sitemaps/xml/${id}`,
          )
          return data.map((d) => ({
            url: `/view/${d.id}`,
            lastmod: convertJapanDate(d.lastmod)
          }))
        },
        exclude: ['/**'],
        cacheTime: 1000 * 60 * 15
      }))
    }
  },
  ...
}
```

上記の設定でサーバを立ち上げると、意図通りの Sitemap が生成されます。
これであっさり完成できたと思い、達成感に浸っていましたが、Sitemap が動的に生成し直されているかの動作確認の際に事件は起こりました。
悲しいことに `sitemap` 関数の実行は**サーバー立ち上げの１回のみ**となっており、それ以降はサーバーを再起動しない限り実行されない感じでした。。。
cacheTime で設定された時間にも期待しましたが、[公式](https://sitemap.nuxtjs.org/usage/sitemap-options#cachetime-optional---number)にあるように

> Defines how frequently sitemap routes should be updated (value in milliseconds).
Setting a negative value will disable the cache.

とのことで、routes における cacheTime だそう、、、それもありがたいけど、、、、

## `@nuxtjs/sitemap` の中身
実装して問題なく Sitemap ファイルが生成されていることを確認して満足してから地獄に落とされました。
ここまで実装した内容を無駄にはしたくないと思い、`@nuxtjs/sitemap` 処理の中身を追うことにしました。
`@nuxtjs/sitemap` ライブラリのファイル構成は下記のようになっています。

```
@nuxtjs/sitemap
├── lib
│   ├── builder.js
│   ├── cache.js
│   ├── generator.js
│   ├── logger.js
│   ├── middleware.js
│   ├── module.js
│   ├── options.js
│   └── routes.js
...
```

このライブラリの `package.json` を確認すると

```
{
...
  "main": "lib/module.js",
...
}
```

とあるので、エントリーポイントは `lib/module.js` であることがわかります。
なのでそのファイルを確認しに行きます。
処理を１つ１つ説明していくと、主旨で説明したい内容とずれてくるので、細かい説明は省略させていただきます。
 `lib/module.js` を追っていくと、`lib/middleware.js` にたどり着き、その処理の中で下記のような処理が行われています。

```javascript:lib/middleware.js
...
  // Add server middleware for sitemap.xml
  nuxtInstance.addServerMiddleware({
    path: options.path,
    /**
     * @param {Request} req
     * @param {Response} res
     * @param {*} next
     */
    async handler(req, res, next) {
      try {
        // Init sitemap
        const routes = await cache.routes.get('routes')
        const xml = await createSitemap(options, routes, base, req).toXML()
        // Check cache headers
        if (validHttpCache(xml, options.etag, req, res)) {
          return
        }
        // Send http response
        res.setHeader('Content-Type', 'application/xml')
        res.end(xml)
      } catch (err) {
        /* istanbul ignore next */
        next(err)
      }
    },
  })
...
```

ざっくりの内容にはなりますが、`@nuxtjs/sitemap` は nuxt の [ServerMiddleware](https://nuxtjs.org/docs/configuration-glossary/configuration-servermiddleware/) を使用し、開発者側で定義した xml のファイルパスへのリクエストが来た際の handler 処理を定義することで xml ファイルの表示を行なっているようでした。
つまり、静的ファイルを生成するのではなく、サーバーのエンドポイントを設定し、そのエンドポイントが叩かれると `Content-Type: application/xml` のレスポンスを返す（xml ファイルをレスポンスする）ように設定されていました。
また、この ServerMiddleware の設定処理は初回のみの実行となるため、動的に Sitemap ファイルのパスが変わるかもしれないケースに対応できないことが判明します。

## `@nuxtjs/sitemap` と cron を組み合わせてみる
`@nuxtjs/sitemap` で xml ファイルをどう表示させているかが把握できました。
そこで、僕はこの `@nuxtjs/sitemap` の処理と cron を組み合わせれば、無事今回実現したい動的な Sitemap ファイルの生成がうまくいくのではないかと思いました。

### cron ライブラリのインストール
cron 実行では `node-cron` ライブラリを用いるため、そのライブラリをインストールします。

```
$ yarn add node-cron
$ yarn add -D @types/node-cron
```

### カスタム module 作成
`@nuxtjs/sitemap` 同様な形式でカスタムモジュールを作成するように修正してみました。
カスタムモジュールでは、しっかり TypeScript に対応するため[公式](https://typescript.nuxtjs.org/ja/cookbook/modules/)に記載ある通りに実装します。

```ts:modules/sitemap.ts
import { Module } from '@nuxt/types'
import cron from 'node-cron'

const sitemap = require('@nuxtjs/sitemap')

const sitemapModule: Module = function() {
  let cronJob: cron.ScheduledTask | null = null

  sitemap.call(this)

  // On "listen" ("nuxt start" or "nuxt dev") mode, generate static files for each sitemap
  this.nuxt.hook('listen', () => {
    cronJob = cron.schedule('* * * * *', () => {
      sitemap.call(this)
    })
  })

  // On "close" mode, stop cron job
  this.nuxt.hook('close', () => {
    if (cronJob) {
      cronJob.stop()
    }
  })
}

export default sitemapModule
```

一旦 cron の実行間隔は毎分実行としました。
`@nuxtjs/sitemap` は型定義が存在しないため、`require` で import してきてます。
sitemap ライブラリを実行する際は、こちらの nuxtInstance を渡せるよう `.call(this)` を指定します。
cron を登録するタイミングは [nuxt hooks](https://nuxtjs.org/docs/configuration-glossary/configuration-hooks/) の `listen` とし、nuxt が `close` した際にしっかり cron を終了するように設定を忘れず記載します。

### nuxt.config へカスタム module を登録
最後に `nuxt.config.ts` に上記作成したカスタムモジュールを登録します。

```ts:nuxt.config.ts
  ...
  modules: [
    ...
    // @nuxtjs/sitemap,
    '@/modules/sitemap'
  ],
  ...
```

### いざ、動作確認！
実行の準備が整ったので、いざ、実行！っと動作確認を行いました。
結果は、変わらずできませんでした。。。
cron の実行など処理は正常に動作していましたが、毎分 `@nuxtjs/sitemap` の処理を実行しても結果は変わらず、初回実行したもののみのファイルが参照できる状態となりました。
なぜできなかったかは、推測になりますが、ServerMiddleware は**サーバーが listen する前**に登録するもので、listen して以降に再登録をしてもそこは意味がないのだと思われます。
まぁ普通に考えて、途中でエンドポイントが増えるなんてあり得ないですからね、、、w

ここまでの調査で `@nuxtjs/sitemap` ライブラリは下記のことが言えます。

* **`@nuxtjs/sitemap` ライブラリは XML ファイルのパスはサーバー起動のタイミングで決定する、起動中で XML ファイルパスは変更できない**
* **XML ファイル内の URL などのコンテンツは動的生成可能**

つまり、今回実現したいことはこのライブラリでは無理ということになります。。。

## さぁ！ `@nuxtjs/sitemap` を捨てよう！
結局 `@nuxtjs/sitemap` は静的な Sitemap ファイルにしか対応していないことがわかりました。
なので `@nuxtjs/sitemap` ライブラリとはおさらばし、代わりに `@nuxtjs/sitemap` 内でも利用していた [sitemap](https://www.npmjs.com/package/sitemap) ライブラリを用いて愚直に xml ファイルを生成する方法に変更します。

```
$ yarn remove @nuxtjs/sitemap
$ yarn add sitemap
```

ライブラリのインストール完了後、先ほど作成した `modules/sitemap.ts` 内を下記のように書き換えれば、完了となります。

```ts:modules/sitemap.ts
import fs from 'fs'
import { resolve } from 'path'
import axios from 'axios'
import { Module } from '@nuxt/types'
import cron from 'node-cron'
import logger from 'consola'
import { SitemapStream } from 'sitemap'
import { convertJapanDate } from './util/date'

type ReturnTypeInPromise<T extends (...args: any) => Promise<any>> = T extends (...args: any) => Promise<infer R> ? R : any

const getSitemapOptions = async () => {
  const response = await axios.get<number[]>('http://localhost:8080/sitemaps/xml/ids')
  return response.data.map((id) => ({
    path: `/sitemaps/${id}.xml`,
    async routes() {
      const { data } = await axios.get<{ id: number; lastmod: string }[]>(`http://localhost:8080/sitemaps/xml/${id}`)
      return data.map((d) => ({
        url: `/view/${d.id}`,
        lastmod: convertJapanDate(d.lastmod)
      }))
    }
  }))
}

const registerSitemap = async (outDir: string, option: ReturnTypeInPromise<typeof getSitemapOptions>[number]) => {
  const sitemapStream = new SitemapStream({
    hostname: 'http://localhost:3000'
  })

  const routes = await option.routes()
  if (!routes.length) {
    return
  }

  const resolvePath = resolve(`${outDir}${option.path}`)
  sitemapStream.pipe(fs.createWriteStream(resolvePath))

  routes.forEach((route) => {
    sitemapStream.write(route)
  })

  sitemapStream.end()

  logger.success('Generated sitemap file', resolvePath)
}

const registerSitemaps = async (outDir: string, options: ReturnTypeInPromise<typeof getSitemapOptions>) => {
  for (const option of options) {
    await registerSitemap(outDir, option)
  }
}

const sitemapModule: Module = function() {
  let cronJob: cron.ScheduledTask | null = null

  // On "listen" ("nuxt start" or "nuxt dev") mode, generate static files for each sitemap
  this.nuxt.hook('listen', async () => {
    const options = await getSitemapOptions().catch((err) => {
      logger.error('get sitemap option error', err)
      return null
    })
    if (!options) {
      return
    }

    const outDir = this.options.dir?.static ? `./${this.options.dir?.static}` : './static'
    await registerSitemaps(outDir, options).catch((err) => logger.error('sitemap register error', err))

    cronJob = cron.schedule('* * * * *', () => {
      logger.start('sitemap cron job')
      registerSitemaps(outDir, options).catch((err) => logger.error('[cron] sitemap register error', err))
    })
  })

  // On "close" mode, stop cron job
  this.nuxt.hook('close', () => {
    if (cronJob) {
      cronJob.stop()
      logger.success('stop sitemap cron job')
    }
  })

  // On "generate" mode, generate static files for each sitemap
  this.nuxt.hook('generate:done', async () => {
    const options = await getSitemapOptions().catch((err) => {
      logger.error('get sitemap option error', err)
      return null
    })
    if (!options) {
      return
    }

    const outDir = this.options.generate?.dir || './dist'
    await registerSitemaps(outDir, options).catch((err) => logger.error('generate sitemap register error', err))
  })
}

export default sitemapModule
```

上記ソースは、アルゴリズムを共有するためなのでソース自体は全て１ファイルに収めています。細かい部分で考慮漏れなロジックなどありますが（ファイル作成する前にディレクトリを作成するなど）、ソースが長くなることを防ぎたかったため、割愛させていただきます、、、すいません、、、
上記実装を参考にする場合は、ファイル構成やロジック等をもっと良くしてご利用ください:bow:
また、cron の実行間隔は毎分にしていますので、**実際の運用に合わせた設定とすることを忘れないよう**お気をつけください、、、！

処理の中身は本当にシンプルです。
`@nuxtjs/sitemap` でのオプション設定で用いたソース（ `path` や `routes` など ）を用いつつ、その値から Sitemap ファイルを書き出す感じになってます。
書き出し先は静的ファイルを配置する static にしています。こちらに配置することでサーバー起動後、書き出されたファイルパスへアクセスするとファイルのコンテンツが確認できます。

```
static/sitemap.xml
↓
http://localhost:3000/sitemap.xml
```

今回の実装でファイルを書き出すロジックに変更になりました。（上記ソースの `registerSitemap` 関数）
こうすることで cron 実行した場合でも毎回ファイルを書き出すようになるためファイルが上書きされます。

また、上記ソースを確認してもらうとわかったかと思いますが、`generate` フックも追加しています。

```ts
this.nuxt.hook('generate:done', async () => { /* ... */ }
```

しっかり generate した際でも Sitemap ファイルが書き出されるようにすることを忘れてはいけません。

### gitignore 設定
今回の実装で cron 実行の際は static 静的ディレクトリ配下に Sitemap ファイルを書き出すようにしました。
なので、吐き出すファイルは gitignore に設定することを忘れないようにします。
以下のソースはあくまでも例です。

```:.gitignore
static/sitemap.xml
static/sitemaps/*.xml
```

## まとめ
定期的に変更される Sitemap ファイルの生成方法をまとめましたが、個人的に「なんとかできた」という温度感です。
というのも static にファイルを吐き出すやり方はいずれ .gitignore のメンテが大変になりそうな所感があり、保守性が低いと思っています。
もっといいやり方があって欲しいと強く思っているので、他に実装方法がありましたらコメントいただけると嬉しいです。:bow:

あまり記事がなかったのでこちら記事にしましたが、同じ悩みをお持ちの方への解決の糸口につながったり、少しでも参考になったとなれば大変嬉しく思います！！