📑

Next.jsでのブログページの多言語機械翻訳を考える

2023/12/16に公開


https://adventar.org/calendars/8910

ちょっと株式会社 Advent Calendar 2023年 12月 16日の記事です。

はじめに

こんにちは、ちょっと株式会社のフロントエンドエンジニア、かこなーるです。
今回は先日検討する機会のあった、機械翻訳を含む多言語化対応についての記事を書きます。

近年i18n対応を行うプロダクトも多いかと思いますが、その中でもNext.jsでブログのようなコンテンツに機械翻訳を利用した構成の記事はあまり見かけませんでしたので、今回はそれについて書きたいと思います。

なぜ機械翻訳なのか

通常、Next.jsで多言語対応を行う場合、各言語ごとに記事コンテンツを実際に翻訳して作成することが多いと思います。
実際、この方法は多言語対応の最良の方法だと考えています。というのも、Googleの検索エンジンでは自動生成されたコンテンツが検索エンジンスパムと判定される可能性があるためです。

しかし、サイトリニューアルの案件では、既存の数百または数千のブログコンテンツを多言語に翻訳するのは時間がかかりますし、多数の言語に対応する場合、記事の作成者が全言語の記事を一度に執筆するのは難しいでしょう。
そこで今回は、翻訳済みのブログコンテンツがない場合、アクセスがあった際に機械翻訳を利用するようにしました。

構築する環境

Node: 18.16.0
Next.js: 13.2.4
next-intl: 3.1.2
CMS: お好きなヘッドレスCMS

i18n対応

ディレクトリ構造

今回の実装ではPages Routerを利用しました。

 L components/
 L locales/
      L ja/
         L common.json
      L en/
         L common.json
      L fr/
         L common.json
 L pages/
      L index.page.tsx
      L blog/
         L [contentId].page.tsx
 L next.config.js

localesディレクトリには、以下のような言語ごとの表示文字列を記載したjsonファイルを格納しています。

ja/common.json
{
  "header": {
    "top": "トップ",
    "blog": "ブログ",
  },
  ...etc
}

ルーティング

まず、機械翻訳での翻訳機能の実装前に、言語ごとのサブパスまたはドメインごとのルーティングを設定します。
Next.jsでは、ロケールごとのサブパスルーティングまたはドメインごとのルーティング設定がbuilt-inで用意されているため、比較的簡単に実装が可能です。
https://nextjs.org/docs/pages/building-your-application/routing/internationalization

以下のように設定します。

module.exports = {
  i18n: {
    locales: ['ja', 'en', 'fr'],
    defaultLocale: 'ja',
  },
  ...
};

useRouterからlocaleの情報を取得することもできます。

import { useRouter } from 'next/router'

export const Header: React.FC = () => {
  // jaやenのような設定した言語表記がstring型で入っています。
  const { locale } = useRouter();
  ...
}

言語ごとの翻訳の出しわけ

ルーティングについてはNext.jsのbult-inで比較的容易に実装が可能なのですが、言語ごとの翻訳の出しわけなどは独自で実装するか、ライブラリを利用する必要があります。
今回は翻訳の出し分けにnext-intlを利用します。
https://next-intl-docs.vercel.app/

npm install next-intl
// または
yarn add next-intl

導入は簡単で、Pages Routerの場合は_app.tsxにproviderを追加するだけです。

_app.tsx
import {NextIntlClientProvider} from 'next-intl';
import {useRouter} from 'next/router';
 
export default function App({Component, pageProps}) {
  const router = useRouter();
 
  return (
    <NextIntlClientProvider
      locale={router.locale}
      timeZone="Asia/Tokyo"
      common={pageProps.common}
    >
      <Component {...pageProps} />
    </NextIntlClientProvider>
  );
}

上記の対応を行えば後は各pageレベルで翻訳したjsonを呼び出せます。

pages/about.page.tsx
const getStaticProps: GetStaticProps<IndexProps> = async ({
  locale,
}) {
  return {
    props: {
      common: (await import(`@/locales/${locale}/common.json`)).default
    }
  };
}

componentで翻訳jsonを利用したい場合は、useRouterを利用することでも利用できます。
ただ、基本的にはページから渡すようにしましょう。

export const Footer: React.FC = ({ ...props }) => {
  const router = useRouter();
  const [footerLang, setFooterLang] = useState();

  useEffect(() => {
    (async function () {
      const getCommon = async () => {
        return (await import(`@/locales/${router.locale}/common.json`)).default;
      };
      const common = await getCommon();

      setFooterLang(common.footer);
    })();
  }, [router.locale]);
  ...
}

これで静的な部分についてはjsonファイルを呼び出すだけで、翻訳対応を行うことができるようになりました。
次はCMSで管理しているブログ記事などのコンテンツです。

ブログ記事の機械翻訳コンテンツ生成

まずコンテンツを管理するCMSについては特に定まったものはありません。
機械翻訳については今回GoogleのCloud translation APIを利用しています。

Next.jsのISRでページを生成することを前提として、下記のような流れと仕組みでコンテンツを生成します。

上記のような処理をコンテンツに対して行うことにより、GETリクエストがあるタイミングでのみページの生成を行います。こうすることで、1度該当のページにアクセスがあれば翻訳されたデータをCMS上で管理することが出来るようになります。
CMSで管理できますので翻訳された内容に問題があった場合も修正が可能です。

Next.jsで行う具体的な実装としては下記のような実装をしています。

// HTMLタグを翻訳しないようテキストのみを取り出し翻訳を行う関数
const translateHtmlWithoutParsing = async (htmlString: string, locale) => {
  // HTMLタグを識別する正規表現
  const regex = /(<[^>]*>)/;
  // HTMLをタグとテキストに分割
  const parts = htmlString.split(regex);

  // テキストのみを翻訳
  for (let i = 0; i < parts.length; i++) {
    if (parts[i] !== undefined && !parts[i]?.startsWith('<')) {
      // textTranslate関数はCloud translationへ翻訳のリクエストを行っている関数です
      // Cloud translationはリクエスト数ではなく文字数での課金のため短い文節でリクエストを行っています。
      parts[i] = await textTranslate({
        text: parts[i] ?? '',
        target: locale,
      });
    }
  }

  // 結合して結果を返す
  return parts.join('');
};

export const getStaticProps = async ({
  params,
  locale
}: {
  params: Params;
  locale: string;
}) => {
  // コンテンツのID 
  const contentId = params?.contentId;
  
  // 翻訳済みのコンテンツをfetch
  const hasTranslate = await fetchTranslateBlogContent({
    filters: `blog[equals]${contentId}[and]language[equals]_${locale}`,
  });
  
  if(hasTranslate.length > 0) {
     return {
       props: {
         blog: hasTranslate[0],
        locale,
        common: (await import(`@/locales/${locale}/common.json`)).default,
      },
      revalidate: 3600,
    };
  }
  
  const blogContent = await fetchBlogContent({ contentId });
  
  // ブログのタイトルを翻訳
  const translateTitle = await textTranslate({
    text: post?.title ?? '',
    target: locale,
  })
  .then((r) => r)
  .catch((error) => error);
  
  // ブログの内容を翻訳
  const translateContent = blogContent.content ? await translateHtmlWithoutParsing(blogContent.content, locale) : undefined;
  
  // 翻訳されたコンテンツをPOSTして保存
  await createTranslateBlogContent({
    filters: `blog[equals]${contentId}[and]language[equals]_${locale}`,
  });

  return {
    props: {
      blog: {
        title: translateTitle,
	content: translateContent,
      },
      locale,
      common: (await import(`@/locales/${locale}/common.json`)).default,
    },
    revalidate: 3600,
  };
};

一度翻訳された内容が登録されれば、次回からは翻訳済みの内容を最初から呼び出されるためある程度コストを抑えた運用が可能です。

注意点

1. 同タイミングでのアクセスによる重複コンテンツの生成
ほぼ同時に特定のページへアクセスがあった場合に稀ではありますが、翻訳コンテンツが重複して作成される可能性があることには注意が必要です。現状のアナリティクスなどから海外からのアクセスがどの程度あるかなど分析した上で慎重な導入が必要です。

2. クローラーの巡回
各検索エンジンのクローラーなどが巡回してきた際、大量に機械翻訳のコンテンツが生成される場合があるため注意が必要です。
基本的にクローラーはユーザーがページを閲覧するのと同じような挙動を取ります。
そのため、クローラーがページを閲覧しに来ると一気に翻訳ページが生成されてしまいます。
そうなると機械翻訳に利用しているAPIなどの利用料が想定を超えて請求されてしまう可能性が高いため、クローラーが自動生成を行うページをあまり巡回しないような対応を行う必要があります。

具体的にはrobots.txtに対して下記のようにDisallowを設定することでクローラーが巡回するのを抑制するのも一つの手段です。

Disallow: /en/blog/
Disallow: /fr/blog/
Disallow: /ru/blog/

ただし、robots.txtはあくまでクローラーに対して「お願い」レベルの指示をするだけですので、場合によっては巡回される可能性があります。一時的にコストが増大する可能性があることは理解しておきましょう。
また、翻訳が生成されたコンテンツはAllowで指定するなどして、クローラーが巡回できるようにする仕組みも持たせましょう。

最後に

海外からのアクセスが過剰に多いサイトなどについては懸念点もあるため使うことが難しいかもしれませんが、要件次第では採用可能な構成になりました。
ただ、基本的にはコンテンツの翻訳は手動で行なうようにしていきたいですね。

参考

https://zenn.dev/mybest_dev/articles/324aed92f8086f
https://next-intl-docs.vercel.app/docs/getting-started

chot Inc. tech blog

Discussion