もふもふ技術部

IT技術系mofmofメディア

技術ブログをNuxt + Netlify + Contentfulから、はてなブログ for DevBlogに移行しました

こんにちは。出口です。

タイトルにある通り、技術ブログをはてなブログに移行しました。

この記事では、なぜ移行することになったのか、どうやって移行したのか、移行で苦労したところなどをまとめておきたいと思います。

もし脱セルフホストブログ、脱Contentfulや、はてなブログへの移行をお考えであれば参考になるのではないかと思います。

なぜ移行したのか

まずそもそもなぜ移行したのかという話から。

タイトルにもあるように、Nuxtを使って独自に開発を行っていましたが、開発から数年経ち、いくつかの問題がありました。

大きく分けると2つです。

  1. Nuxt 3への移行が大変すぎる
  2. Contentfulへの不満が募ってきた

Nuxt 3への移行が大変すぎる

Nuxt + Netlify + Contentfulで構成されたブログの開発当初2020年4月ごろは、日本だとVueが比較的人気だったころです。

GoogleトレンドでVueとReactを比較したもの(2019年〜2021年)

trends.google.co.jp

その頃のmofmofはVueを採用することも多々あったんですが、今はトレンドの移り変わりも相まって、VueよりもReactの方が得意という人が増え、Vue、Nuxtが分かる人が相対的に少なくなりました。

そうなると困るのがNuxt 2から3への移行です。

Nuxt 2から3への移行が大変すぎるのは有名な話ですが、技術ブログについても同様の問題を抱えていました。

Contentfulへの不満が募ってきた

2つあります。

1つ目が、料金体系です。

一昨年の4月からエンジニアリングコーチとして携わるようになり、その一環でアウトプット量を増やすためにメンバーのみんなにブログを書いてもらうように計画したことがあったんですが、全員を招待すると料金が大変なことになると話したことがありました。確か20人以上になると…みたいな話だった覚えがあります。

その計画自体は別の理由により上手くいかなかったんですが、その後もブログを書いて欲しいとお願いするとContentfulの料金どうしましょうねという話が出てくる事態が続いていました。

もう1つが、ContentfulのUI等の問題です。

エディタが使いにくい、プレビュー、タグ、画像の扱いがし辛いなどの、使い勝手の部分への不満です。

以上の理由により、別の何かに移行しようという話になりました。それが一昨年2022年末から2023年頭にかけての話です。

当初の計画

2023年4月時点では、Next + Notionにする方向でまとまっていました。

Nuxt → Next、Contentful → Notion という感じです。

NextはApp Routerが出始めぐらいで、これは今後のNextはどうなるんだろうかと不安な時期でしたが、NextならキャッチアップもしているのでNuxtよりはなんとかなるだろうという判断をしました。

Notionは普段から利用しているので、メンバーは基本参加していますし、記事の執筆もしやすいだろうという理由で採用しました。

ただ、これも上手く行きませんでした。方向性としては良さそうだったんですが、主動しているメンバーの案件が忙しくなったりしていて、上手く進められなくなってしまいました。

改めて移行を考える

そして2023年末に再び記事を書いていこういう計画が立ち上がり、だとするとブログをなんとかしたいね。となった次第です。

今回は前回の反省を活かして、開発リソースは使えない前提で考えました。

候補はいくつかありましたが、はてなブログに決定しました。

サブディレクトリオプションが使えるのも決め手の1つになっています。

移行について

そんなこんなで移行することになったので、はてなブログについて詳細を色々調査しました。

以下、一部調査結果を抜粋します。

  1. 一括で移行するには、Movable TypeかWordPress形式(WXR)のデータが必要
  2. 記事のインポートはオーナー権限でしか行えない
  3. インポートで登録した記事は著者情報(作成者情報)が設定できない

    インポートしたオーナー権限のアカウントに自動的に紐付けられます。これも罠ですね。

    我々としては書いた人の情報は大切だと思っているので、WXRで設定出来るようにしてもらえると嬉しいのですが、問い合わせをしたところ、インポート時にもインポート後にも設定出来ないとのことだったので、ちょっと工夫して対応しました。詳しくは後述します。

  4. 記事URLの形式は

    • 標準:/entry/2011/11/07/161845
    • ダイアリー:/entry/20111107/1320650325
    • タイトル:/entry/2011/11/07/週末は川に行きました

    のどれかが基本

    それとは別に記事ごとにカスタム:/entry/[自由入力]が選べます。移行記事についてはカスタムを使いました。

    ちなみに、/entry/の部分は変更出来ます。

    staff.hatenablog.com

    インポート時にしか出来ないのでちょっと罠感があります。

  5. DevBlogにすると公式のまとめサイトに掲載される

    流入が増えるのは良いことなので、ちょっと期待しています。

    hatena.blog

記事移行

前述の通り、著者情報をどうやって移行するかが課題でした。

今回は以下のパターンに分けて対応することにしました。

  1. 在籍中 and 個人名義で移行したい人 and 記事が少ない人
    1. 手動で対応してもらう
  2. 在籍中 and 個人名義で移行したい人 and 記事が多い人
    1. AtomPubで取り込んでもらう
  3. 個人名義じゃなくてよい人 or 退職済みの人
    1. インポート機能を使ってWXRを取り込む

著者情報を維持しつつ、かんたんに移行するならAtomPubを使うしかないかなと思います。

AtomPubは、個人アカウントの情報が必要なので、コードはこちらで用意して、それをローカルマシンで実行してもらう形を取りました。詳細は後述。

インポート機能を使ってWXRを取り込む場合

記事管理にContentfulを使っていたので、CLIを使ってJSON形式でデータを出力します。

WXR形式への変換はスクリプトを作りました。2023年12月末時点で動作確認しています。

import fs from "fs";
import { create } from "xmlbuilder2";
import { parseISO, format } from "date-fns";
import { formatInTimeZone } from "date-fns-tz";

function createWxrXml(entries, tags, excludedAuthorIds) {
  const root = {
    rss: {
      "@version": "2.0",
      "@xmlns:excerpt": "http://wordpress.org/export/1.2/excerpt/",
      "@xmlns:content": "http://purl.org/rss/1.0/modules/content/",
      "@xmlns:wfw": "http://wellformedweb.org/CommentAPI/",
      "@xmlns:dc": "http://purl.org/dc/elements/1.1/",
      "@xmlns:wp": "http://wordpress.org/export/1.2/",
      channel: {
        link: "https://tech.mof-mof.co.jp",
        item: entries
          .filter(
            (entry) =>
              !excludedAuthorIds.includes(entry.fields.author?.["en-US"].sys.id)
          )
          .map((entry) => {
            return {
              title: entry.fields.title["en-US"],
              // NOTE: はてなブログのカスタムURLに登録する為、登録用のURLを作る
              // 例)https://tech.mof-mof.co.jp/hogehoge
              // 本当のURLは、https://tech.mof-mof.co.jp/blog/hogehoge
              // 本当のURLのままだと、blog/hogehogeがカスタムURLに登録されてしまう
              // NOTE: itemのlink(この部分)のオリジンと、channel直下のlinkは同じものにする
              link: `https://tech.mof-mof.co.jp/${entry.fields.url["en-US"]}`,
              description: entry.fields.summary?.["en-US"] || "",
              "content:encoded": {
                $: entry.fields.article["en-US"],
              },
              "wp:post_date": formatInTimeZone(
                parseISO(entry.fields.publishedDate["en-US"]),
                "Asia/Tokyo",
                "yyyy-MM-dd HH:mm:ss"
              ),
              "wp:status": "publish",
              "wp:post_type": "post",
              category: entry.fields.tags?.["en-US"].map((entryTag) => {
                const tag = tags.find((t) => t.sys.id === entryTag.sys.id);
                return {
                  "@domain": "category",
                  "@nicename": encodeURIComponent(tag.fields.slug["en-US"]),
                  $: tag.fields.name["en-US"],
                };
              }),
            };
          }),
      },
    },
  };

  const doc = create(root);
  return doc.end({ prettyPrint: true });
}

// JSONデータを読み込む
const contentfulData = JSON.parse(fs.readFileSync("/path/to/something.json", "utf8"));
const entries = contentfulData.entries.filter(
  (entry) => entry.sys.contentType.sys.id === "post"
);
const tags = contentfulData.entries.filter(
  (entry) => entry.sys.contentType.sys.id === "tag"
);
// 除外する著者のID
const excludedAuthorIds = [
  "hogehogehoge",
  "fugafugafuga"
];

// XMLを生成
const wxrXml = createWxrXml(entries, tags, excludedAuthorIds);

// XMLファイルに保存
fs.writeFileSync(
  `output/wxr-${format(
    new Date(),
    "yyyy-MM-dd-HH-mm"
  )}.xml`,
  wxrXml
);

実行するとWXRファイルが出力されるので、オーナー権限のアカウントでインポートすればOKです。

記事内の画像については、一部を除き画像データの移行メニューから移行出来ました。 移行出来なかった画像については、手動で対応しています。

AtomPubを使ったパターンの場合

インポート機能利用時と同様にContentful CLIでJSONファイルを出力したものを使います。

こちらも前述の通り、スクリプトを組みました。

import axios from "axios";
import { create } from "xmlbuilder2";
import { parseISO, format } from "date-fns";

function convertEntryToXml(entry, tags) {
  const xmlObj = {
    entry: {
      "@xmlns": "http://www.w3.org/2005/Atom",
      "@xmlns:app": "http://www.w3.org/2007/app",
      title: entry.fields.title["en-US"],
      content: {
        "@type": "text/x-markdown",
        "#": entry.fields.article["en-US"],
      },
      updated: parseISO(entry.fields.publishedDate["en-US"]).toISOString(),
      category: entry.fields.tags?.["en-US"].map((entryTag) => {
        const tag = tags.find((t) => t.sys.id === entryTag.sys.id);
        return {
          "@term": tag.fields.name["en-US"],
        };
      }),
      "app:control": {
        "app:draft": "no", // NOTE: コードを試しに実行する場合は`yes`に変更して下書き投稿する
        "app:preview": "no",
      },
      "hatenablog:custom-url": {
        "@xmlns:hatenablog": "http://www.hatena.ne.jp/info/xmlns#hatenablog",
        "#": entry.fields.url["en-US"],
      },
    },
  };

  const doc = create(xmlObj);
  return doc.end({ prettyPrint: true });
}

// JSONデータを読み込む
const contentfulData = JSON.parse(fs.readFileSync("/path/to/something", "utf8"));
const entries = contentfulData.entries.filter(
  (entry) => entry.sys.contentType.sys.id === "post"
);
const tags = contentfulData.entries.filter(
  (entry) => entry.sys.contentType.sys.id === "tag"
);

// 著者によるフィルタリング
const filteredEntries = contentfulData.entries.filter(
  (entry) =>
    entry.fields.author?.["en-US"].sys.id === process.env.CONTENTFUL_AUTHOR_ID
);

// 各エントリをXMLに変換し、はてなブログに送信
filteredEntries.forEach(async (entry) => {
  const xmlData = convertEntryToXml(entry, tags); // XML変換処理

  const url = `https://blog.hatena.ne.jp/${process.env.HATENA_BLOG_OWNER_ID}/${process.env.HATENA_BLOG_ID}/atom/entry`;
  try {
    const response = await axios.post(url, xmlData, {
      headers: {
        "Content-Type": "application/xml",
        Authorization: `Basic ${Buffer.from(
          `${process.env.HATENA_ID}:${process.env.HATENA_API_KEY}`
        ).toString("base64")}`,
      },
    });
    console.log(`Entry posted successfully: ${response.data}`);
  } catch (error) {
    console.error("Error posting entry:", error);
  }
});

※HATENA_ID、HATENA_API_KEYは個人で発行したものを指定する ※HATENA_BLOG_OWNER_ID、HATENA_BLOG_IDは、このブログの場合だとそれぞれmofmof-incmofmof-inc.hatenablog.comとなります

AtomPubを使った移行だと画像がうまく移行出来なかったので、手動で対応しました。結構大変でした。

サブディレクトリオプション

データ移行が完了して、公開の準備が出来てから利用しました。 運用開始後に設定することも出来るんですが、うまく設定出来ていないとアクセス出来なくなる時間が発生したりして大変だと思ったので、公開前に実施しました。

コーポレートサイトのドメイン配下の/tech-blogに設定しています。

Netfilyのリバースプロキシ設定

コーポレートサイトもNetfilyを使って運用しているので、Netfilyでリバースプロキシを設定する必要がありました。 Netlifyのドキュメント、フォーラムでのやり取りを見ている感じ、実際に設定できるか少し怪しかったんですが問題なく動いています。

出来るならnginx、fastly等のはてなブログが検証済みのサービスを使って設定する方が良いと思います。

[[redirects]]
  from = "/tech-blog/*"
  to = "https://0123456789.hatenablog-oem.com/tech-blog/:splat"
  status = 301
  force = true
  headers = { X-Forwarded-Host = "www.mof-mof.co.jp", X-Hatena-Blog-Subdirectory-Token = "1234567890abcdef" }

_redirectファイルでリダイレクトの設定は可能ですが、カスタムヘッダーが必要なため、netlify.tomlの方で設定しています。

robots.txtを設置

サブディレクトリオプションを使うときの推奨設定らしいので、コーポレートサイトのrobots.txtに追加しました。

User-agent: *
Disallow: /tech-blog/api/
Disallow: /tech-blog/draft/
Disallow: /tech-blog/preview
Sitemap: https://www.example.com/tech-blog/sitemap_index.xml

User-agent: Mediapartners-Google
Disallow: /tech-blog/draft/
Disallow: /tech-blog/preview

NetlifyのPrerenderingオプション設定

Prerenderingオプション(記事執筆時点ではベータ版)を使っているとサブディレクトリオプションの検証に失敗してしましました。

これをオフにすることで、検証ツールでの検証もOKになったので、こちら使っている場合はオフにした方が良さそうです。

検証ツールで1つだけ検証失敗する

「はてなブログのサーバーが返したLocationヘッダが正しくクライアントに到達している」という検証に失敗してしまいます。

ざっと見たところ問題は大きくなさそうなので無視して使うことにしました。

最後に、サブディレクトリで動くのが確認出来たら、元々の技術ブログからリダイレクトするようにNetfilyのリダイレクト設定を行って完了です。

細かいことをいうと、画像の移行とかカテゴリーの整理とかやってたりしますが割愛します。

まとめ

以上で、はてなブログへの移行が完了しました。

この記事がはてなブログで見れているのであれば、移行が成功しているということだと思います。

長い記事になりましたが、ここまでご覧いただきありがとうございました。

今後は、はてなブログで技術的な発信をしていきたいと思います。よろしくお願いします。