🚀

Qiitaの記事をHexoに移行した際の技術的な手順とスクリプト

に公開

背景

過去に Qiita で多くの記事を書いていましたが、ブログプラットフォームを静的サイトジェネレータである Hexo に一本化することにしました。Qiita の記事には今でも一定のアクセスがあるため、それらを適切にリダイレクトしつつ、コンテンツを Hexo 環境に移行する必要がありました。

この記事では、Qiita API を利用して全記事を取得し、Hexo の形式に変換するまでの一連のプロセスと、そのために作成した Node.js スクリプトについて技術的な詳細をまとめます。

移行の全体像

移行プロセスは、大きく分けて以下の3つのステップで構成されます。

  1. 全記事データの取得: Qiita API v2 を使って、自分のアカウントの全記事データを JSON 形式で取得します。
  2. Hexo 形式への変換と画像ダウンロード: 取得した JSON データを解析し、各記事を Hexo が認識できる Markdown ファイル(Front-matter を含む)に変換します。同時に、記事内で参照されている画像をダウンロードし、ローカルパスに書き換えます。
  3. 手動での微調整とデプロイ: 自動変換だけでは対応しきれない部分(例: 埋め込みコンテンツの修正)を手動で調整し、Hexo でサイトを生成してデプロイします。

1. 全記事データの取得

Qiita API v2 を利用します。API を叩くにはアクセストークンが必要です。
[Qiita > 設定 > アプリケーション] から個人用のアクセストークンを発行します。read_qiita スコープがあれば十分です。

記事一覧を取得するエンドポイントは https://qiita.com/api/v2/authenticated_user/items ですが、一度に取得できるのは最大100件です。そのため、全記事を取得するにはページネーションを考慮する必要があります。

以下は、全記事を取得するための Node.js スクリプトの骨子です。

fetch-all-articles.js
import fs from 'fs/promises';
import fetch from 'node-fetch';

const QIITA_API_TOKEN = process.env.QIITA_API_TOKEN;
const QIITA_API_ENDPOINT = 'https://qiita.com/api/v2/authenticated_user/items';
const PER_PAGE = 100;

async function fetchAllArticles() {
  let allItems = [];
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    console.log(`Fetching page ${page}...`);
    const url = `${QIITA_API_ENDPOINT}?page=${page}&per_page=${PER_PAGE}`;
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${QIITA_API_TOKEN}`,
      },
    });

    if (!response.ok) {
      throw new Error(`API request failed: ${response.statusText}`);
    }

    const items = await response.json();
    
    if (items.length > 0) {
      allItems = allItems.concat(items);
      page++;
    } else {
      hasMore = false;
    }
  }
  
  await fs.writeFile('qiita-articles.json', JSON.stringify(allItems, null, 2));
  console.log(`Successfully fetched ${allItems.length} articles.`);
}

fetchAllArticles();

このスクリプトを実行すると、カレントディレクトリに qiita-articles.json というファイルが生成されます。

2. Hexo 形式への変換と画像ダウンロード

次に、qiita-articles.json を読み込み、各記事を個別の Markdown ファイルに変換します。

Hexo の記事ファイルは、以下のような Front-matter を持ちます。

---
title: 記事のタイトル
date: 2023-10-27 12:00:00
tags: [tag1, tag2]
---

記事の本文 (Markdown)

変換スクリプトでは、以下の処理を行う必要があります。

  • JSON の各要素をループ処理する。
  • title, created_at, tags を抽出して Front-matter を生成する。
  • body (Markdown 本文) を取得する。
  • 本文中の画像 URL (![alt](url)) を正規表現で検出し、画像をダウンロードしてローカルパスに置き換える。
  • 記事 ID (id) をファイル名として、Markdown ファイルを保存する。
convert-to-hexo.js
// (一部抜粋)
import fs from 'fs/promises';
import path from 'path';
import fetch from 'node-fetch';

// ... (JSON読み込み処理) ...

for (const article of articles) {
  const frontMatter = `---
title: "${article.title.replace(/"/g, '\"')}"
date: ${article.created_at.replace('T', ' ').replace(/\+.*/, '')}
tags: [${article.tags.map(tag => tag.name).join(', ')}]
---`;

  let body = article.body;

  // 画像のダウンロードとパスの置換
  const imageUrls = body.match(/!\[.*?\]\((https?:\/\/[^\s]+)\)/g) || [];
  for (const imgTag of imageUrls) {
    const url = imgTag.match(/\((.*?)\)/)[1];
    const filename = path.basename(new URL(url).pathname);
    const imagePath = path.join('source/images', filename);
    const localPath = `/images/${filename}`;

    // 画像をダウンロード
    const imgResponse = await fetch(url);
    const buffer = await imgResponse.arrayBuffer();
    await fs.writeFile(imagePath, Buffer.from(buffer));

    // 本文のURLをローカルパスに置換
    body = body.replace(url, localPath);
  }

  const content = `${frontMatter}\n\n${body}`;
  const filePath = path.join('source/_posts', `${article.id}.md`);
  await fs.writeFile(filePath, content);
}

3. 手動での微調整

  • リダイレクト設定: Qiita 側で記事を削除または非公開にする前に、リダイレクト設定が必要です。もしサーバー側でリダイレクトを設定できるなら、qiita.com/user/items/item_id から新しいブログの URL へリダイレクトルールを追加します。
  • 埋め込みコンテンツ: Gist や YouTube などの埋め込みコンテンツは、Hexo のタグプラグインなどに合わせて手動で修正が必要になる場合があります。
  • 内部リンク: Qiita 内の他の自分の記事へのリンクは、新しいブログのパスに手動で修正する必要があります。

まとめ

Qiita から Hexo への移行は、API と簡単なスクリプトを使えば、大部分を自動化することが可能です。特に記事数が多い場合、手作業での移行は現実的ではありません。このプロセスを通じて、コンテンツを自分の管理下に置き、より柔軟なブログ運営が可能になりました。


この記事はAIによって修正・追記されました。

Discussion