1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-17

この記事はC3 Advent Calendar 2022 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

  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」の4つのコミュニティーから構成されています。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」の4つのコミュニティーから構成されています。',
      },
      {
        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

  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

求める表示
ブログページのサムネイルやタイトル、説明文、URLなどでOGPを表示したい。
image.png

Cloudflare Workersでの実現

Cloudflare Workersはエッジサーバーでスクリプトを実行してくれるサーバーレスのサービスで、トリガーを設定することで、任意のURLに対するリクエストに対して処理を行えたりします。

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

Cloudflare Workersの導入

  1. Cloudflareにログインし、Workersを開き、Set upする
    スクリーンショット 2022-12-17 16.27.39.png

  2. Create a Serviceを選択する。
    スクリーンショット 2022-12-17 16.28.57.png

  3. Continue with Freeを選択する。
    スクリーンショット 2022-12-17 16.29.58.png

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

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

スクリーンショット 2022-12-17 16.31.04.png

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

スクリーンショット 2022-12-17 16.32.06.png

  1. トリガーの設定
    Add routeでトリガーとなるURLパターンを指定する。
    スクリーンショット 2022-12-17 17.16.37.png

実装

この実装は先輩の@rkunkunrさんが実装してくれたものをベースにしています。

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

レスポンスを見るとヘッダー情報が書き換わっていることが分かります。これで、動的なOGP表示ができるようになりました。
image.png

その他の方法

prerender.ioを用いる

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

vercel/ogを用いる

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

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

Netlifyのprerendering機能を使う

prerendering
β版機能ではあるがSPAやSSGのようなサイトを事前にレンダリングできる機能らしいです。これを使えば問題なくOGP表示が出来ると思います。

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

AWSやAzureなどのクラウドエッジサービスを使う

Cloudflareのエッジサービスを用いたが、AWSやAzureのエッジサービスを用いても原理的には同じです。

SSGの場合なら記事や作品投稿があるたびにビルド!

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

VPSなどでNginxなどを用いてエッジサービス的なものを実装

原理的にはできそうだが、コスト的にも実装的にも面倒

まとめ

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

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

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

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

次回予告

明日(19日目)のC3 Advent Calendar 2022はぽあさんの「iPad+Procreateエンジョイライフ」です.お楽しみに!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?