SPA・SSGにおける動的OGP対応について

この記事は[C3 Advent Calendar 2022](https://qiita.com/advent-calendar/2022/c3) 18日目の記事です

今回はSPA・SSGにおける動的OGP対応についてです。

# 概要
SPAやSSGのサイトのSEO対策を行う際にOGP対応をしたいということがあると思います。私も同じ状況になったのでその時のことをまとめて記事にしたいと思います。

# 背景
サークルで作品投稿のできるサイトやブログサイトをSSGでデプロイし、SEO対策の一環でOGP対応を行いたいました。そこで、動的な作品・ブログ詳細ページなどのページの＊*OGPが表示されない現象**に遭遇しました。これはTwitterなどのクローラーがブラウザーのように**JavaScriptを実行してくれない**ため、JavaScriptで動的にmetaタグを書き換えているようなSPAやSSGなどの場合にはOGP表示ができない。

じゃあ、最初からSSRで実装して、デプロイすればいいじゃないかと思うかもしれません。

しかし、SSRのサイトを動かすにはNode.jsの実行環境が必要になるほか、サーバーの負荷が大きい問題があります。特にデプロイ先に困るほか、金銭的な問題もあります。SPAやSSGの場合はGitHub Pagesのような静的ホスティングサービスでデプロイができるため無料でデプロイができるのです！

部活の公式サイトはGitHub Pages、作品投稿サイトはVPS（低スッペック、リソースカツカツで運用中）上にデプロイしています。

私は学生ですし、サークル関連のサイトではあるものの、サークルの貴重なため、可能な限り無料枠などで実現したい！ということで、それを実現するために奮闘した結果得られたものをまとめたいと思います。

# 前提
**背景**
- 課金は極力したくない
- **SPA**もしくは**SSG**でのホスティング

**環境**
- フロントエンドフレームワーク：Nuxt.js
- GitHub Pages　もしくはVPS
- CDN：Cloudflare

**読む人に求めるもの**
- SPA・SSG・SSRの違いをある程度把握していること

# 本編
結論から言うと**Cloudflare Workersでmetaタグを書き換える**方法です！

## 状況
`nuxt.config.js`

```javascript
  head: {
    title: 'Composite Computer Club [ C3 ]',
    htmlAttrs: {
      lang: 'ja',
      prefix:
        'og: https://ogp.me/ns# fb: https://ogp.me/ns/fb# website: https://ogp.me/ns/website#',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'copyright', content: '©　2022 Composite Computer Club.' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      {
        hid: 'description',
        name: 'description',
        content:
          '九州工業大学情報工学部の Composite Computer Club 通称「C3」の公式サイトです。C3はデジタル作品の制作を行なっているサークルで、現在、「GAME、HACK、CG、MEDIA_ART」の４つのコミュニティーから構成されています。This is the official site of the Composite Computer Club, commonly known as "C3," in the Faculty of Information Technology at Kyushu Institute of Technology. C3 is a club that produces digital works and currently consists of four communities: GAME, HACK, CG, and MEDIA_ART.',
      },
      { name: 'format-detection', content: 'telephone=no' },
      { 'http-equiv': 'Cache-Control', content: 'no-cache' },
      { name: 'twitter:card', content: 'summary_large_image' },
      { name: 'twitter:site', content: '@c3_kyutech' },
      { hid: 'og:url', property: 'og:url', content: process.env.BASE_URL },
      {
        hid: 'og:site_name',
        property: 'og:site_name',
        content: 'Composite Computer Club [ C3 ]',
      },
      {
        hid: 'og:title',
        property: 'og:title',
        content: 'Composite Computer Club [ C3 ] - ホーム',
      },
      { hid: 'og:type', property: 'og:type', content: 'website' },
      {
        hid: 'og:description',
        property: 'og:description',
        content:
          '九州工業大学情報工学部の Composite Computer Club 通称「C3」の公式サイトです。C3はデジタル作品の制作を行なっているサークルで、現在、「GAME、HACK、CG、MEDIA_ART」の４つのコミュニティーから構成されています。',
      },
      {
        hid: 'og:image',
        property: 'og:image',
        content: 'https://simo-c3.github.io/image_url/c3_logo_circled.png',
      },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' }],
  },
```

`pages/blog/index.vue`

```javascript
  head() {
    this.title = `ブログ - ${this.blog_item.fields.title}`
    this.description = `${this.title} - ${this.blog_item.fields.digest}`
    this.img_url = `http:${this.blog_item.fields.thumbnail.fields.file.url}`
    return {
      title: this.title,
      meta: [
        {
          hid: 'description',
          name: 'description',
          content: this.description,
        },
        {
          hid: 'og:url',
          property: 'og:url',
          content: `${process.env.BASE_URL}blog/${this.$route.params.id}`,
        },
        {
          hid: 'og:title',
          property: 'og:title',
          content: this.title,
        },
        { hid: 'og:type', property: 'og:type', content: 'article' },
        {
          hid: 'og:description',
          property: 'og:description',
          content: this.description,
        },
        {
          hid: 'og:image',
          property: 'og:image',
          content: this.img_url,
        },
      ],
      link: [
        {
          hid: 'canonical',
          rel: 'canonical',
          href: `${process.env.BASE_URL}blog/${this.$route.params.id}`,
        },
      ],
    }
  },
```

上記のようにブログページのmetaタグをJavaScriptで記述しています。

**Twitterでの表示**
`nuxt.config.js`に記述した静的なmetaタグでOGPが表示されてしまっています。
![Group 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/219bb59a-81a8-9337-80c2-8f659b096f44.png)

**求める表示**
ブログページのサムネイルやタイトル、説明文、URLなどでOGPを表示したい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/58f52a7e-c49b-2a9f-0b4e-10c7a4e38945.png)

## Cloudflare Workersでの実現
[Cloudflare Workers](https://workers.cloudflare.com/)はエッジサーバーでスクリプトを実行してくれるサーバーレスのサービスで、トリガーを設定することで、任意のURLに対するリクエストに対して処理を行えたりします。

今回はOGPリクエストが来た際に返却するHTMLのヘッダーをWorkersで書き換えるように処理を行います。

### Cloudflare Workersの導入
1. Cloudflareにログインし、Workersを開き、`Set up`する
    ![スクリーンショット 2022-12-17 16.27.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/c85478ef-0d57-c700-79a9-3976c7b0dcfe.png)

2. `Create a Service`を選択する。
    ![スクリーンショット 2022-12-17 16.28.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/084b72a3-5910-256d-11fc-9f3fb5283940.png)

3. `Continue with Free`を選択する。
    ![スクリーンショット 2022-12-17 16.29.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/346b2b3f-9352-7f66-83f0-9ae1ea2cd818.png)

4.　service nameを入力し、`Create service`を選択する。

:::note info
`Select a starter`は初期記述の選択で、後から全部書き換えるので、どっちを選択しても問題ない。
:::

 ![スクリーンショット 2022-12-17 16.31.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/1a0f04e4-1d76-0d49-26b4-b9b6a34efe85.png)

以下のような画面になる。

![スクリーンショット 2022-12-17 16.32.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/66c1265b-fcca-1d21-a94d-79060d016541.png)

5. トリガーの設定
`Add route`でトリガーとなるURLパターンを指定する。
![スクリーンショット 2022-12-17 17.16.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/e308154e-7926-0980-921d-5d618cfc2cf9.png)


### 実装
この実装は先輩の[@rkunkunr](https://qiita.com/rkunkunr)さんが実装してくれたものをベースにしています。

```javascript
var __accessCheck = (obj, member, msg) => {
  if (!member.has(obj))
    throw TypeError("Cannot " + msg);
};
var __privateAdd = (obj, member, value) => {
  if (member.has(obj))
    throw TypeError("Cannot add the same private member more than once");
  member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
};
var __privateMethod = (obj, member, method) => {
  __accessCheck(obj, member, "access private method");
  return method;
};

// src/util/description_trim.ts
function trimDescription(description, maxLength = 100, extension = "...") {
  const lines = description.split("\n");
  let isTrimed = false;
  let trimed = lines.reduce((prev, current) => {
    if (prev.length + current.length > maxLength - extension.length) {
      isTrimed = true;
      return prev;
    }
    return prev + "\n" + current;
  });
  return isTrimed ? trimed + extension : trimed;
}

// src/injectors/index.ts
function ogRewriteHandlerFactory(pageType, params) {
  return {
    element(e) {
      const property = e.getAttribute("property");
      if (property === "og:title" && params.title) {
        e.setAttribute("content", `${pageType} | ${params.title}`);
        return;
      }
      if (property === "title" && params.title) {
        e.setAttribute("content", `${pageType} | ${params.title}`);
        return;
      }
      if ((property === "og:description" || e.getAttribute("name") === "description") && params.description) {
        e.setAttribute("content", trimDescription(params.description));
        return;
      }
      if ((property === "description" || e.getAttribute("name") === "description") && params.description) {
        e.setAttribute("content", trimDescription(params.description));
        return;
      }
      if (property === "og:image" && params.imageURL) {
        e.setAttribute("content", params.imageURL);
        return;
      }
      if (property === "image" && params.imageURL) {
        e.setAttribute("content", params.imageURL);
        return;
      }
      if (property === "og:url" && params.url) {
        e.setAttribute("content", params.url);
        return;
      }
    }
  };
}

// src/util/api.ts
var _get, get_fn;
var ApiClient = class {
  constructor(API_URL, SPACE_ID, CDA_ACCESS_TOKEN, ENVIRONMNET_ID) {
    __privateAdd(this, _get);
    this.API_URL = API_URL;
    this.SPACE_ID = SPACE_ID;
    this.CDA_ACCESS_TOKEN = CDA_ACCESS_TOKEN;
    this.ENVIRONMNET_ID = ENVIRONMNET_ID;
  }

  async getEntry(id) {
    const entry = await __privateMethod(this, _get, get_fn).call(this, id);
    return entry;
  }
};

_get = new WeakSet();

get_fn = async function(id) {
    const url = `${this.API_URL}/spaces/${this.SPACE_ID}/environments/${this.ENVIRONMNET_ID}/entries?sys.id=${id}&include=10`;
    const res = await fetch(url, {
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${this.CDA_ACCESS_TOKEN}`
      }});
    console.debug(res)
    if (res.status !== 200) {
        throw Error(`Error occured ${res.statusText}`);
    } else {
        console.log('ok')
    }
    const body = await res.json();
    return body;
};

// src/injectors/users.ts
async function userRewriter(ctx) {
  const apiClient = new ApiClient(ctx.env.API_URL, ctx.env.SPACE_ID, ctx.env.CDA_ACCESS_TOKEN, ctx.env.ENVIRONMNET_ID);
  const res = await apiClient.getEntry(ctx.params.id);
  const user = res.items[0]
  const imageID = user.fields.icon.sys.id;
  var image = ''
  res.includes.Asset.forEach((asset) => {
    if (asset.sys.id === imageID) {
      image = asset.fields.file.url
    }
  })
  const handlers = ogRewriteHandlerFactory('著者', {
    title: user.fields.name,
    description: user.fields.introduction || "\u30D7\u30ED\u30D5\u30A3\u30FC\u30EB\u304C\u3042\u308A\u307E\u305B\u3093",
    imageURL: `https:${image}?fm=jpg&q=10` || "https://toybox.compositecomputer.club/_nuxt/img/ToyBoxlogo.21166b5.png",
    url: ctx.req.url
  });
  return new HTMLRewriter().on("meta", handlers).transform(ctx.originRes);
}

// src/injectors/blog.ts
async function blogRewriter(ctx) {
  const apiClient = new ApiClient(ctx.env.API_URL, ctx.env.SPACE_ID, ctx.env.CDA_ACCESS_TOKEN, ctx.env.ENVIRONMNET_ID);
  const res = await apiClient.getEntry(ctx.params.id);
  const blog = res.items[0]
  const imageID = blog.fields.thumbnail.sys.id
  var image = ''
  res.includes.Asset.map((asset) => {
    if (asset.sys.id === imageID) {
      image = asset.fields.file.url
      return
    }
  })
  const handlers = ogRewriteHandlerFactory('ブログ', {
    title: blog.fields.title,
    description: blog.fields.digest,
    imageURL: `https:${image}?fm=jpg&q=10`,
    url: ctx.req.url
  });
  return new HTMLRewriter().on("meta", handlers).transform(ctx.originRes);
}

// src/injectors/news.ts
async function newsRewriter(ctx) {
  const apiClient = new ApiClient(ctx.env.API_URL, ctx.env.SPACE_ID, ctx.env.CDA_ACCESS_TOKEN, ctx.env.ENVIRONMNET_ID);
  const res = await apiClient.getEntry(ctx.params.id);
  const news = res.items[0]
  const imageID = news.fields.thumbnail.sys.id
  var image = ''
  res.includes.Asset.map((asset) => {
    if (asset.sys.id === imageID) {
      image = asset.fields.file.url
      return
    }
  })
  const handlers = ogRewriteHandlerFactory('お知らせ', {
    title: news.fields.title,
    description: news.fields.digest,
    imageURL: `https:${image}?fm=jpg&q=10`,
    url: ctx.req.url
  });
  return new HTMLRewriter().on("meta", handlers).transform(ctx.originRes);
}

// src/router.ts
async function route(env, req, originRes) {
  const pathname = new URL(req.url).pathname;
  if (pathname.slice(-1) !== '/') {
    pathname += '/'
  }
  const newsScan = /^\/news\/([A-z0-9-]+)/.exec(pathname);
  if (newsScan && newsScan[1]) {
    console.debug("rewrite news response");
    return await newsRewriter({
      env,
      pathname,
      req,
      originRes,
      params: {
        id: newsScan[1]
      }
    });
  }
  const blogScan = /^\/blog\/([A-z0-9-]+)\/?$/.exec(pathname);
  if (blogScan && blogScan[1]) {
    console.debug("rewrite blog response");
    return await blogRewriter({
      env,
      pathname,
      req,
      originRes,
      params: {
        id: blogScan[1]
      }
    });
  }
  const userScan = /^\/author\/([A-z0-9-]+)\/?$/.exec(pathname);
  if (userScan && userScan[1]) {
    console.debug("rewrite user response");
    return await userRewriter({
      env,
      pathname,
      req,
      originRes,
      params: {
        id: userScan[1]
      }
    });
  }
  return null;
}

// src/index.ts
var src_default = {
  async fetch(request, env, ctx) {
    const originRes = await fetch(request);
    if (request.method === "GET") {
      try {
        const injectedRes = await route(env, request, originRes);
        if (injectedRes)
          return injectedRes;
      } catch {
        return originRes;
      }
    }
    return originRes;
  }
};
export {
  src_default as default
};
//# sourceMappingURL=index.js.map
```

**`src_default`**
最初に呼ばれる処理。
`const originRes = await fetch(request);`でキャッチしたrequestからデータをフェッチし取得している。
`route()`を呼び出し、パスごとの処理を行う。

**`route()`**
正規表現を用いてパスに応じた処理を行う。

**`(news, blog, user)Rewriter()`**
APIで必要な情報を取得し、必要なデータを`ogRewriteHandlerFactory`に渡す。
`ogRewriteHandlerFactory`の返り値で、metaデータの書き換えを行い、値を返却する。

`HTMLRewriter()`の詳細は↓
https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/

**`ogRewriteHandlerFactory()`**
引数を元にelementを作り、返す。

**`trimDescription()`**
discriptionが長すぎる場合にパンクズで省略する。

**`ApiClient`**
APIへのリクエストクライアント。必要に応じて各自のAPIリクエスト処理を記述する。今回はcontentfulへのリクエストを行っている。

### 実行テスト
中央のURL入力欄にOGP表示したいURLを入力し、`Send`すると実際にリクエストが飛び、そのリスポンスとlogを見ることができる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/65f5f58c-fae6-9c8c-fee1-72445c2e258f.png)

レスポンスを見るとヘッダー情報が書き換わっていることが分かります。これで、動的なOGP表示ができるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634748/ae77750f-5a33-3ede-04ae-134e509df865.png)

## その他の方法
### [prerender.io](https://prerender.io/)を用いる
先ほど実装したようなことをやってくれるサービスで、無料枠もあるためこれを用いる手もある。
私も使いましたがsitemap.xmlの更新をしないと正しくOGPが生成されない現象が起き、使い勝手が微妙だったため採用しなかった。また、キャッシュが無料枠だと１週間であるため、変更しても反映までに時間が非常にかかってしまう。金銭的余裕があるのであれば、課金すると便利になると思われます。

### vercel/ogを用いる
vercelが提供するOGP生成機能を用いることで、簡単にOGP画像生成ができるだけっぽい。結局metaタグがSSRで記述されていることが前提になっているっぽい？そもそもvercelにGitHubの組織リポジトリをビルドするのは有料となるため、今回は諦めた。SPAやSSGでもうまいことJavaScriptを実行して表示してくれるのかも？

ちゃんと調べていないので、やってみたい人は調べてみてください。すいません😞

### Netlifyのprerendering機能を使う
[prerendering](https://docs.netlify.com/site-deploys/post-processing/prerendering/)
β版機能ではあるがSPAやSSGのようなサイトを事前にレンダリングできる機能らしいです。これを使えば問題なくOGP表示が出来ると思います。

しかし、今期はカスタムドメインの設定で、すでに使用されているというエラーで登録できないため、Netlifyは採用しませんでした。（お問い合わせで対応してもらえるらしいが時間がかかるので、放置している。）
また、無料枠での運用が難しいそうなので、採用していません。

### AWSやAzureなどのクラウドエッジサービスを使う
Cloudflareのエッジサービスを用いたが、AWSやAzureのエッジサービスを用いても原理的には同じです。
<iframe src="//www.slideshare.net/slideshow/embed_code/key/iFi7xZoCoDSsrJ" width="595" height="485" frameborder="0" marginwidth="0" marginheight="0" scrolling="no" style="border:1px solid #CCC; border-width:1px; margin-bottom:5px; max-width: 100%;" allowfullscreen> </iframe> <div style="margin-bottom:5px"> <strong> <a href="//www.slideshare.net/rs_wisteria/spaogp" title="SPA時代のOGPとの戦い方" target="_blank">SPA時代のOGPとの戦い方</a> </strong> from <strong><a href="//www.slideshare.net/rs_wisteria" target="_blank">Yoichi Toyota</a></strong> </div>

### SSGの場合なら記事や作品投稿があるたびにビルド！
この方法は脳筋な実装なのでおすすめはしない。バッチ的にビルドするのも記事や作品投稿後すぐにOGP生成されないので使い勝手が悪いし、これも脳筋なのでよろしくない。

### VPSなどでNginxなどを用いてエッジサービス的なものを実装
原理的にはできそうだが、コスト的にも実装的にも面倒

## まとめ
Twitterなどのクローラーは**JavaScriptを実行してくれない**ので、動的ページのmetaタグのレンダリングもJavaScriptで行っている**SPAやSSGの場合、動的OGPを表示できない**。SSGの場合はビルドの時に事前にレンダリングしておくこともできるが、定期的に記事や作品が投稿されるようなサイトでは何度もビルドを行う必要があり、良い実装ではない。

対策方法はTwitterなどのクローラーの代わりに**動的なmetaのレンダリングを行うようにEdgeサービス**で対応することである。つまり、CloudflareやAWS、Azure、などので実装が可能である。また、[prerender.io](https://prerender.io/)のようなサービスを利用することでも解決できる。さらに、vpsやレンタルサーバーなどでNginxやApacheなどを用いて実装することも可能だと思われる。

各自の構成に合った方法を選択すれば良いと思う。

SPAやSSGでのOGP対応が今後さらに簡単になればいいが、現状自前で処理実装を行うのが金銭的にも自由度的にもいいのかもしれない。

# 次回予告
明日（19日目）のC3 Advent Calendar 2022はぽあさんの「iPad+Procreateエンジョイライフ」です．お楽しみに！