😽

【Nuxt.js】定期更新される動的URLを持つサイトマップ作成方法

2023/12/26に公開

はじめに

Nuxt.jsを使って動的なURLを持つサイトマップを作成したので学習の一環としてこちらにまとめておきます。フロントエンドをNuxt.js、バックエンドをrailsで実装しており、ECSコンテナで運用しています。またNuxtは2系、sitemapライブラリは7.1.1を使用しています。

こちらの記事を参考にさせて頂きました。
https://zenn.dev/shiminori/articles/786edfb1c5c2de

サイトマップの仕様についてはこちら
https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap?hl=ja

前提

  • URLはDBの値を元に動的に変更される
  • 記載するURLは50000件を超える為サイトマップインデックスが必要
  • URLのカテゴリ毎にサイトマップインデックスを分割して作成したい
  • 更新は毎日行う

概要

  • 上記の記事に基づきsitemapライブラリを使用
  • サイトマップを生成するカスタムモジュールの作成
  • ECSタスクのスケジューリング機能を使用してカスタムモジュールファイルを定期実行
  • サイトマップはEFSを使用して永続化(ECSコンテナ内に保存してもコンテナが再起動すると消えてしまう為)

具体的な実装

ディレクトリ構造

app以下

├── static
│   │   ├── sitemaps
│   │   │   ├── sitemap_indexes
│   │   │   │   ├── sitemap_index_category1.xml
│   │   │   │   ├── sitemap_index_category2.xml
│   │   │   │   ├── sitemap_index_category3.xml
│   │   │   └── sitemaps
│   │   │       ├── category1
│   │   │       │   └── sitemap_category1_1.xml
│   │   │       │   └── sitemap_category1_2.xml
│   │   │       ├── category2
│   │   │       │   └── sitemap_category2_1.xml
│   │   │       │   └── sitemap_category2_2.xml
│   │   │       ├── category3
│   │   │       │   └── sitemap_category3_1.xml

処理の流れ

generateSitemapメソッドの中身そのままですが、

  1. 新しいサイトマップディレクトリの作成
  2. 新しいサイトマップインデックスディレクトリの作成
  3. サイトマップの作成(記載するURLはバックエンドコンテナから取得)
  4. サイトマップインデックスの作成
  5. 既存のサイトマップ、サイトマップインデックスディレクトリの削除
  6. 手順1.2で作成したディレクトリのリネーム
modules/sitemap.ts
import fs from 'fs'
import path from 'path'
import axios from 'axios'
import logger from 'consola'
import { SitemapStream, SitemapIndexStream } from 'sitemap'
import * as Sentry from '@sentry/node'

type UrlResponse = {
  urls: {
    url: string
    lastmod: string
  }[]
}
type Urls = {
  url: string
  lastmod: string
}[]

const sitemapDirectory = './app/static/sitemaps/sitemaps'
const sitemapIndexDirectory = './app/static/sitemaps/sitemap_indexes'
const newSitemapDirectory = './app/static/sitemaps/new_sitemaps'
const newSitemapIndexDirectory = './app/static/sitemaps/new_sitemap_indexes'

const createNewSitemapDirectory = async () => {
  // newSitemapDirectoryが残っている場合を想定
  if (fs.existsSync(newSitemapDirectory)) {
    await fs.promises.rm(newSitemapDirectory, {
      recursive: true,
      force: true,
    })
  }
  await fs.promises.mkdir(newSitemapDirectory)
}

const createNewSitemapIndexDirectory = async () => {
  // newSitemapIndexDirectoryが残っている場合を想定
  if (fs.existsSync(newSitemapIndexDirectory)) {
    await fs.promises.rm(newSitemapIndexDirectory, {
      recursive: true,
      force: true,
    })
  }
  await fs.promises.mkdir(newSitemapIndexDirectory)
}

const createNewEachSitemapDirectry = async (type: string) => {
  if (!fs.existsSync(newSitemapDirectory)) {
    handleError(
      `Error createNewEachSitemapDirectry()`,
      new Error(`createNewEachSitemapDirectry エラー : ${type}`)
    )
  }

  await fs.promises.mkdir(`${newSitemapDirectory}/${type}`)
}

const createSitemap = async (
  urlArray: Urls,
  urlType: string,
) => {
  let sitemapStream: SitemapStream | null = null
  let fileCount = 1
  // URLが空の場合、少なくとも1ファイル作成されるようにする
  const totalUrls = urlArray.length === 0 ? 1 : urlArray.length

  for (let index = 0; index < totalUrls; index++) {
    const url = urlArray[index] || { url: '', lastmod: '' }
    if (index % 50000 === 0) {
      // 前のサイトマップストリームが存在すれば、そのストリームを終了
      if (sitemapStream) {
        await new Promise((resolve) => {
          sitemapStream!.on('finish', resolve)
          sitemapStream!.end()
        })
      }

      // 新しいサイトマップストリームを作成
      sitemapStream = new SitemapStream()
      const pathWord = `${urlType}${fileCount}`
      const directry = `${urlType}`
      const resolvePath = `${newSitemapDirectory}/${directry}/sitemap_${pathWord}.xml`
      sitemapStream.pipe(fs.createWriteStream(resolvePath))
      fileCount++
    }

    // URLをサイトマップストリームに書き込み
    if (sitemapStream) {
      url.url = `${process.env.APP_HOST}${url.url}`
      sitemapStream.write(url)
    }
  }

  // 最後のサイトマップストリームを終了
  if (sitemapStream !== null) {
    await new Promise((resolve) => {
      sitemapStream!.on('finish', resolve)
      sitemapStream!.end()
    })
  }
}

const getUrlData = async (endpoint: string) => {
  const { data } = await axios.get<UrlResponse>(
    `${process.env.API_SERVER_BASE_URI}/${endpoint}`
  )
  if (!data?.urls) {
    handleError(`Error getUrlData()`, new Error(`APIエラー : ${endpoint}`))
  }

  return data.urls
}

const generateEachSitemap = async (sitemapType: string, endpoint: string) => {
  const urls = await getUrlData(endpoint)
  await createNewEachSitemapDirectry(sitemapType)
  await createSitemap(urls, sitemapType).catch((err) =>
    handleError(`Error createSitemap('${sitemapType}'`, err)
  )
}

const generateAllSitemap = async () => {
  await Promise.all([
    generateEachSitemap('category1', 'category1/urls').catch((err) =>
      handleError(
        `Error generateEachSitemap('category1', 'category1/urls')`,
        err
      )
    ),
    generateEachSitemap('category2', 'category2/urls').catch((err) =>
      handleError(`Error generateEachSitemap('category2', 'category2/urls')`, err)
    ),
  ])
}

const createEachIndexFile = async (indexFileType: string) => {
  const writeStream = fs.createWriteStream(
    `${newSitemapIndexDirectory}/sitemap_index_${indexFileType}.xml`
  )
  const indexStream = new SitemapIndexStream()
  indexStream.pipe(writeStream)

  const newSitemapEachDirectry = `${newSitemapDirectory}/${indexFileType}`
  const sitemapFiles = await fs.promises.readdir(newSitemapEachDirectry)
  if (sitemapFiles.length === 0) {
    indexStream.write({})
  } else {
    for (const file of sitemapFiles) {
      const baseUrl = path.join(
        `${ドメイン名}}`
        `sitemaps/sitemaps/${indexFileType}`,
        file
      )
      const fullUrl = `https://${baseUrl}`
      indexStream.write({
        url: fullUrl,
      })
    }
  }
  indexStream.end()

  await new Promise((resolve) => writeStream.on('finish', resolve))
}

const createIndexFiles = async () => {
  const indexFileTypes: Array<string> = [
    'category1',
    'category2',
  ]
  for (const indexFileType of indexFileTypes) {
    await createEachIndexFile(indexFileType)
  }
}

const isSitemapGenerated = async () => {
  // 各サイトマップディレクトリに1つ以上サイトマップが生成されているか確認
}

const isSitemapIndexGenerated = async () => {
  // サイトマップインデックスが作成されたことを確認(sitemap_indexファイル数を確認)
}

const deleteOldDirectory = async () => {
  await isSitemapGenerated().catch((err) =>
    handleError('Error isSitemapGenerated()', err)
  )
  await isSitemapIndexGenerated().catch((err) =>
    handleError('Error isSitemapIndexGenerated()', err)
  )

  if (fs.existsSync(sitemapDirectory)) {
    await fs.promises.rm(sitemapDirectory, {
      recursive: true,
      force: true,
    })
  }

  if (fs.existsSync(sitemapIndexDirectory)) {
    await fs.promises.rm(sitemapIndexDirectory, {
      recursive: true,
      force: true,
    })
  }
}

const renameNewDirectory = async () => {
  await fs.promises.rename(newSitemapDirectory, sitemapDirectory)
  await fs.promises.rename(newSitemapIndexDirectory, sitemapIndexDirectory)
}

const handleError = (errorMessage: String, err: Error) => {
  logger.error(errorMessage, err)
  throw err
}

const generateSitemap = async () => {
  try {
    await createNewSitemapDirectory().catch((err) =>
      handleError('Error createSitemapDirectory()', err)
    )
    await createNewSitemapIndexDirectory().catch((err) =>
      handleError('Error createSitemapIndexDirectory()', err)
    )
    await generateAllSitemap().catch((err) =>
      handleError('Error generateAllSitemap()', err)
    )
    await createIndexFiles().catch((err) =>
      handleError('Error createIndexFiles()', err)
    )
    await deleteOldDirectory().catch((err) =>
      handleError('Error deleteOldDirectory()', err)
    )
    await renameNewDirectory().catch((err) =>
      handleError('Error renameNewDirectory()', err)
    )
  } catch (err) {
    Sentry.captureException(err)
    logger.error('Error during the initialization of sitemap creation:', err)
  }
}

// Node.jsでこのファイルが直接実行された場合generateSitemapが実行される
if (require.main === module) {
  generateSitemap()
}

yarnコマンドで実行できるようにscriptに登録

package.json
  {
  ...
  "scripts": {
    // 追加
    "create-sitemap": "ts-node ./modules/sitemap.ts"
  },

以下のコマンドをECSタスク定義のコマンドに指定してECSタスクスケジュールに登録

yarn create-sitemap

ポイント

Promise.all

引数にPromiseを返す非同期処理を配列形式で複数渡すことができる。全てのPromiseが解決された時、または1つでも拒否された時に単一のPromiseを返します。複数の処理を並列で実行して全てが完了した時に次の処理に進みたい場合に便利です。

S3→EFS移行

最初はサイトマップをS3に保存していました。サイトマップインデックスへのリクエストが来たらserverMiddlewareを使ってS3のURLへリダイレクトさせる方法です。
しかしこの方法ではgoogleに認識されなかった為、EFSで直接ECSコンテナのディレクトリをマウントする実装に変更しました。
確かサイトマップ単体では認識されましたが、サイトマップインデックスを挟むと認識されませんでした。2回遷移があると認識されなくなるのかもしれません。

シンボリックリンク

新旧サイトマップの置き換えはバージョン管理も兼ねてシンボリックリンクを使用しようと思いましたが、実装コストが増える、もしエラーが発生してサイトマップが短時間無くなっても影響は少ないと思われる、という理由から実装を見送りました。

あとがき

つまずくポイントがなかなか多かったです。S3を使った実装でgoogleに認識されなかった時は泣きそうになりました。。
同じような実装をしている方の参考になれば幸いです。
(コードを変えているのでそのままでは動作しないです。)

Discussion