SPAサイトのサイトマップをスクレイピングで自動生成した話

# 概要
SPAサイト全体をスクレイピングし、サイトマップを自動生成します。因みにSPAのフレームワークは、vue.jsを使用しました。
実装には、以下のNode.jsライブラリを使っています。

- [Chromy](https://github.com/OnetapInc/chromy)（Chromeをヘッドレスブラウザとして操作するためのライブラリ）
- [xml2js](https://github.com/Leonidas-from-XIV/node-xml2js)（XMLの解析と生成を行えるライブラリ）

# 処理の流れ
全体の流れを簡単に説明すると、スクレイピングのスタート地点を指定し、そこから全てのページ内の`a`タグの`href`属性を取得していき、最終的にsitemap.xmlを生成します。

## Chromyでスクレイピング

~~~
const Chromy = require('chromy')

async function scraping(chromy, array, to) {
  await chromy.blockUrls(['*.ttf', '*.gif', '*.png', '*.jpg', '*.jpeg', '*.webp'])
  await chromy.goto(to)

  // 指定の要素が現れるまで処理を中断
  await chromy.wait('body.is-content-loaded')

　// ページ内のaタグのhref属性を全て取得
  const anchors = await chromy.evaluate(()=> {
    const result = []
    for(const item of document.body.getElementsByTagName('a')) {
      if(item.target != '_blank') {
        result.push(item.href)
      }
    }
    return result
  })

  // 配列に格納
  for(let item of anchors) {
    if(array.indexOf(item) == -1) > -1) {
      array.push(item)
    }
  }

  await chromy.close()
}

async function start() {
  const chromy = new Chromy()
  const urls = ['https://sample.com']
  let count = 0

  do {
    await scraping(chromy, urls, urls[count])
    count++
  } while (count < urls.length - 1)
}

start()
~~~

`scraping()`関数は、Webページへのスクレイピングと、スクレイピングした結果から`a`タグの`href`属性を全ての取得し、`urls`配列に格納しています。
SPAではAPIからコンテンツデータを取得し、それをレンダリングする必要があるため、データを取得するにはレンダリングを待たなければなりません。そこで`chromy.wait()`関数で指定した要素（今回は`body.is-content-loaded`）が現れるまで処理を停止しています。
また`urls`配列には、既に配列に格納されていない値のみ格納します。

`start()`関数は、`urls`配列に格納された値を`do while`文でループし、`scraping()`関数に渡すことでサイト内の全てのページをスクレイピングします。
ループしつつ、`scraping()`関数が`urls`配列に重複のない値を格納していくため、最終的に格納できる値がなくなりスクレイピングは終了されます。

## xml2jsでsitemap.xmlを生成

~~~
const xml2js = require('xml2js')
const fs = require('fs')

function xmlBuild(object) {
  const array = []

  // 配列を入れ子に
  for(const item of object) {
    array.push({ url: { loc: item } })
  }

  const builder = new xml2js.Builder({ rootName: 'urlset' }).buildObject({ $: { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' } })
  const xml = builder.buildObject(array)
  fs.writeFile('sitemap.xml', xml, 'utf8')
}

const urls = ['https://sample.com']
xmlBuild(urls)
~~~

配列の構造通りにXMLファイルが生成されるように、`urls`配列の値を入れ子にした新しい配列を生成しています。

# 今回のソースコード
~~~
const Chromy = require("chromy")
const xml2js = require("xml2js")
const fs = require("fs")

async function scrap(chromy, pushArray, to) {
  await chromy.blockUrls(["*.ttf", "*.gif", "*.png", "*.jpg", "*.jpeg", "*.webp"])
  await chromy.goto(to)
  await chromy.wait("body.is-content-loaded")
  const anchors = await chromy.evaluate(()=> {
    const result = []
    for(const item of document.body.getElementsByTagName("a")) {
      if(item.target != "_blank") {
        result.push(item.href)
      }
    }
    return result
  })
  
  for(let item of anchors) {
    if(pushArray.indexOf(item) == -1 && item.indexOf("http://") > -1) {
      pushArray.push(item)
    }
  }

  await chromy.close()
}

function xmlBuild(object) {
  const array = []
  for(const item of object) {
    array.push({ url: { loc: item } })
  }
  const builder = new xml2js.Builder({ rootName: "urlset" }).buildObject({ $: { xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9" } })
  const xml = builder.buildObject(array)
  fs.writeFile("sitemap.xml", xml, "utf8")
}

async function scrapingStart() {
  const chromy = new Chromy()
  const urls = ['https://sample.com']
  let count = 0

  do {
    await scrap(chromy, urls, urls[count])
    count++
  } while (count < urls.length - 1)

  await xmlBuild(urls)
}

start()
~~~

今回は以上です。
実は、スクレイピングを試す前に既存のサイトマップ生成サービス、ライブラリも試してみたのですがどれも上手くいかなかったので、生成できるものを知ってる方がいれば、是非教えてください！