Firebaseホスティングで公開しているSPAのサイトをGoogle検索にひっかかるようにする(SSRなしで試す)

先日公開した「ログ情報共有サービス[LogCrow](https://logcrow.firebaseapp.com)」にて、登録されたログデータに公開フラグがついているものをGoogle検索からでもひっかかるようにしてみました。

LogCrowのシステム構成は[以前の記事](https://qiita.com/ike_dai/items/e4ec039fe8cabeb85361)で紹介した通り、フロントエンドはVue.jsで開発し、Firebaseホスティングを使って公開をしています。バックエンドはGAEのGoのスタンダード環境を利用しています。

元々SPAで単一のページ上でコンテンツを切り替えてログ情報を表示していたのですが、当然それだとGoogleの検索結果としてはトップページしかインデックスされません。各登録ログの情報がインデックス化されるようにチャレンジしてみました。
ついでに、各登録ログの情報をFacebook等で共有したときにその情報がうまく共有されるようOGP設定も入れてみました。

試した流れは以下です。

- 各ログのページに個別のURLが割り当てられるようページ構成を変更
- sitemap.xmlをバックエンドで自動作成し、登録済みログ情報のクローリング依頼リクエストを投げられるにする
- HTMLのヘッダーのメタ情報にOGP設定を追加(Firebase functionsを利用)
- Cloud Schedulerで定期的にsitemap.xmlをクロールするようリクエストを行う

以下、順番に説明します。

## 各ログのページに個別URLを設定

Vue routerの設定で以下のように/log/<ログID>というパスが来たときにLogViewを表示するようなルートを設定します。
このとき、Routerのmode指定を'history'に指定するのが重要です。
デフォルトの設定の場合、'hash'モードになっており、/#/log/<ログID>という感じで実際のpathの手間に#が挟まる形でURL遷移されてしまいます。
普通にブラウザで画面遷移して見る分にはこれでも問題ないのですが、クローラーでチェックされる際、#以降のURLパスが無視されてしまい正しくインデックス化されないです。
なので、historyモードにしてURLパスを認識してもらえるようにします。

```
・・・略
Vue.use(Router)

let router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Main',
      component: Main
    },
    {
      path: '/new',
      name: 'New',
      component: New,
      meta: { requiresAuth: true }
    },
    {
      path: '/log/:logid',
      name: 'LogView',
      component: LogView
    },
・・・略
  ]
})
```

LogViewのコンポーネント側では```this.$route.params.logid```という感じでパスに含まれるlogidの情報を取得して表示内容を切り替えるような実装をすればOKです。

## sitemap.xmlをバックエンドで自動作成し、登録済みログ情報のクローリング依頼リクエストを投げられるにする

各ログのページがどこかからリンクされていてクローラーが順次辿れるようになっていればsitemapをあえて作る必要はないかと思いますが、今回のLogCrowでは登録されたログは検索することで表示されるような仕様となっているため、クローラーがたどるようなことができません。
そこで、sitemapを作ってログの個別ページをクローリング対象にしてもらえるようにします。

ログのデータは随時登録され変更されていくため、静的なsitemap.xmlを作れば良いというわけではありません。
そのため、今回はGAE上で動かしているバックエンド側でsitemap.xmlを返す実装をしました。


```golang

package main

import (
        "time"
        "fmt"
        "net/http"
        "github.com/labstack/echo"
        "google.golang.org/appengine"
        firebase "firebase.google.com/go"
        "google.golang.org/api/iterator"
)

type urlset struct {
        Namespace string `xml:"xmlns,attr"`
        Urls []SitemapUrl `xml:"url"`
}

type SitemapUrl struct {
        Loc string `xml:"loc"`
        Lastmod  time.Time `xml:"lastmod"`
}

func main() {
        if err := profiler.Start(profiler.Config{
                Service:        "logcrow",
                ServiceVersion: "1.0.0",
        }); err != nil {
                fmt.Println("Profiler error")
        }
        //e := echo.New()
        e.Use(middleware.Logger())
        e.Use(middleware.Recover())
        e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
                AllowOrigins: []string{"https://logcrow.firebaseapp.com"},
                AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
                AllowMethods: []string{"GET", "POST", "DELETE", "PUT", "OPTIONS"},
        }))
        private_g := e.Group("", AuthMiddleware)
        public_g := e.Group("")
        public_g.GET("/log", GetLog)
        public_g.GET("/sitemap.xml", GetSitemap)
        appengine.Main()
}
・・・略
func GetSitemap(c echo.Context) error {
        r := c.Request()
        projectID := "..."
        conf := &firebase.Config{ProjectID: projectID}
        ctx := appengine.NewContext(r)
        app, err := firebase.NewApp(ctx, conf)
        if err != nil {
                fmt.Printf("Firebase Error\n")
                fmt.Println(err)
        }
        client, err := app.Firestore(ctx)
        if err != nil {
                fmt.Printf("Firestore Error\n")
        }
        defer client.Close()

        iter := client.Collection("xxx").Documents(ctx)
        urlset := urlset{}
        urlset.Namespace = "http://www.sitemaps.org/schemas/sitemap/0.9"
        for {
                doc, err := iter.Next()
                if err == iterator.Done {
                        break
                }
                if err != nil {
                        fmt.Println("Error")
                        fmt.Println(err)
                }
                sitemapUrl := SitemapUrl{}
                logEntry := LogEntry{}
                doc.DataTo(&logEntry)
                if logEntry.UpdatedAt.IsZero() == true {
                        continue
                }
                sitemapUrl.Loc = "https://logcrow.firebaseapp.com/log/" + doc.Ref.ID
                sitemapUrl.Lastmod = logEntry.UpdatedAt
                urlset.Urls = append(urlset.Urls, sitemapUrl)
        }

        return c.XML(http.StatusOK, urlset)
}
```

こんな感じでfirestoreから取得したデータの内容を元にXMLを組み立て、レスポンスを返します。
sitemap.xmlにアクセスすると以下のような感じのxmlが返却されるようになります。

```
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>
      https://logcrow.firebaseapp.com/log/2yjS48xJDeYtUtK76Gx1Rw-T2SVg6FQ7e6nK5sXY3FA
    </loc>
    <lastmod>2020-01-16T06:11:30.120946Z</lastmod>
  </url>
  <url>
    <loc>
      https://logcrow.firebaseapp.com/log/36KHVJPQYbCEpGRjw3vL-01gpWT0bLV8-qjLgwjeDMM
    </loc>
    <lastmod>2020-02-17T05:27:59.943381Z</lastmod>
  </url>
  <url>
    <loc>
      https://logcrow.firebaseapp.com/log/6ng3kroRQ8j0uKRM-qh3kXKyNy0r3lhNb5p7XnUidMc
    </loc>
    <lastmod>2020-03-19T11:49:28.388672Z</lastmod>
  </url>
</urlset>
```

このsitemapをGoogleにリクエストするには[Google Search Console](https://search.google.com/search-console/about?hl=ja)から登録するか、robots.txtに記載して公開するか、pingで登録するかで依頼することができます。

pingで投げる場合は以下のような感じです。

```
$ curl -XGET http://www.google.com/ping?sitemap=https%3A%2F%2Flogcrow.appspot.com%2Fsitemap.xml
```

いつクローリングされるかは不明なので、しばらく待った後、Google検索でひっかけてみます。
すると以下のような感じで検索でひっかけることが可能です。

![Google_Search_Before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45851/40d69963-c0a4-b31c-8d5f-f2cdf7124476.png)

実際にインデックス登録されているかどうかはGoogle Search Consoleから確認が可能です。

![Google_Search_Console.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45851/54aacac6-2ace-a2ee-342a-7cd1fa335ac1.png)

ここで、検索結果をみると、タイトルがすべてlogcrowとなっており、ログのページの内容毎にタイトルを変えるといったことができていないのがわかります。
また、このログのURLをFacebook等で共有したときの表記としてもindex.htmlに記載されたヘッダー情報を元にした内容になってしまいます。

## HTMLのヘッダーのメタ情報にOGP設定を追加(Firebase functionsを利用)

そこで、ヘッダーのメタ情報にOGP設定を追加してみます。
Vue.jsのSPAの場合、ベースとなるhtmlに固定でヘッダーを仕込むのはできますが、ページ毎に動的にヘッダーを変えるのはちょっと手間がかかります。
今回、以下の2パターンのやり方を試しました。

1. mountedでページ表示時に強引にヘッダーのメタ情報をVue.jsで書き換える方法
2. 動的にヘッダー情報を仕込んだhtmlを返すFirebase functionsを設定する方法

1.については[こちら](https://crieit.net/posts/Vue-js-SPA-head-title-description)で紹介されている方法で実施。
ブラウザ上で確認すると、確かにメタ情報が設定されていることがわかります。
しかし、この方法だと、URLをFacebook等でシェアしようとしたときに、メタ情報が書き換えられる前の情報で認識されてしまいうまくいきません。
そこで、2.の方法で/log/<ログID>でアクセスが来たときに、functionsを実行させ、メタ情報を仕込んだHTMLだけを返し、そのHTMLの中からwindow.locationで実際に見せたいVue.jsのログページに飛ばすような構成にしてみました。
functionで情報を返すというワンステップ処理が増えてあまり綺麗な感じではないのですが、これで処理させるとOGPの設定が効いてきます。

![Facebook_share_debugger.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45851/9fb70cad-fe79-b94e-0992-423d02f45b4f.png)

これでGoogleのクローラーでインデックス化されると以下のような感じで、タイトルとかが設定されて結果が出てきます。

![Google_Search_After.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45851/a004ed8d-76ba-25db-c18d-e11abf5d543d.png)

## Cloud Schedulerで定期的にsitemap.xmlをクロールするようリクエストを行う
最後にsitemap.xmlは先程、手動でリクエストを投げましたが、定期的に自動で実施されるようにし、新しいログの登録があれば自動的に検索にヒットするようにします。
時間ベースでスケジューリングして実行するなら、GCPのCloud Schedulerが便利です。
cronの形式で時間指定して処理を実行できます。特に今回のようなHTTPのリクエストを投げるだけなら、Schedulerの定義だけで完結します。

![CloudScheduler.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45851/b6e23236-9ea9-49c6-73f7-6a5350b36b00.png)

## まとめ

Firebase上でホスティングしていることもありサーバサイドレンダリングせずに済ませる方法を探して色々と試してなんとか動かせました。しかし、ちょっと強引な方法です。
特にOGP設定周りはサーバサイドレンダリング使わないとかなり手間かかります。必要に応じた検討が必要そうです。