はてなブログから移行

·6 分で読めます

このブログをはてなブログからHugo + Firebase Hostingに移行した。はてなブログは2014年から12年間もの間使っていたのでなんとも感慨深い。はてなブログProを年間契約していて、毎年サブスクの更新前になると「そろそろブログ移行しないとな〜」と思ってずっとやれていなかった。しかし、今年はコーディングエージェントの力を借りて課金前に滑り込みで移行することができた。本当にコーディングエージェントはすごい&ありがたいことこの上ない。

なんで移行したの?

独自ドメインで運用したかったためはてなブログは有料のProプランに入っていたのだけど、年間8400円かかっている割にはブログは月に1本記事を書くかどうかの頻度で、「コスパが悪かった」というのが大きな理由。はてなブログの機能自体は便利だったので、そこには全く不満はなかった。むしろHugoだと機能が少なくて不満に思うことがよくある。

なんでHugo?

AstroかHugoかで悩み、Astroはテーマごとに記事の属性(Frontmatterでつけるやつ)が異なるという思想が気に入らなかったのでHugoにした。ただ、色々とテーマを変えている最中に、AstroほどひどくはないけどHugoも似たような思想だと気づいたが😇、もう諦めている。

Hugoのテーマはprimer-blogにした。検索機能がないのがネックだったけど、AIに作ってもらえばいいかと思いそこは妥協した。

なおHugoのブログ記事のコンテンツはもちろんGitHubリポジトリにプッシュしている。

なんでFirebase Hosting?

Firebaseでは設定ファイルでリダイレクトの設定が簡単にでき、そのついでにホスティング環境としても使わせてもらったというだけ。候補としてはCloudflare Pagesも魅力的だったが、わざわざ別のホスティング環境の設定をするのが面倒だったというだけで、それ以上の理由はない。

記事の移行

MT形式のはてなブログの記事をHugoのMarkdown形式に変換するスクリプトはClaude Codeに生成してもらった。はてなブログからはエクスポートの機能を使ってMT形式でファイルとしてエクスポートし、それをこのスクリプトに食わせる。

落とし穴だったのは、Proプランに加入していてもエクスポートした記事にはてなキーワードへのリンクがあって、これを変換時に削除する必要があったこと。ってかなんでProなのにはてなキーワードへのリンクが入ってくるねん、、、

移行手順

まずはてなブログにある記事をエクスポートし、Hugo側のフォーマット(Markdown)に変換して、Firebase Hostingに生成したHTMLをデプロイしておく。

次にFirease Hostingの機能を使って、以下のようなリダイレクトの設定を行い、はてなブログの記事URLでアクセスされた場合はリダイレクトするように設定する。

{
  "hosting": {
    "public": "hugo/public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "redirects": [
      {
        "source": "/entry/:slug*",
        "destination": "/posts/:slug*",
        "type": 301
      },
      {
        "source": "/archive/category/:name",
        "destination": "/categories/:name/",
        "type": 301
      },
      {
        "source": "/archive/:year/:month",
        "destination": "/archives/:year/:month/",
        "type": 301
      },
      {
        "source": "/archive/:year",
        "destination": "/archives/:year/",
        "type": 301
      }
    ]
  }
}

最後にFirebase Hostingで journal.lampetty.net のカスタムドメインの設定を行う。DNSに以下のレコードの定義で、CNAMEの向き先を以下のように変更する

CNAME journal.lampetty.net hatenablog.com.
CNAME journal.lampetty.net blog-lampetty-net.web.app.

しばらく待っているとFirebase側で journal.lampetty.net に対するTLS証明書を取得してくれるので、これのセットアップが終わるまで待つ。自分の場合は10分ぐらいで終わっていた。

あとはリダイレクトがちゃんとされるかなどの確認をして終わりだった。

Hugoで足りない機能

以下ははてなブログにはあった機能だが、Hugo単体では存在しないものだったので、自作した。長くなるので今回は割愛するけど、別記事で詳しく説明したい。

  • デフォルトのOGP画像生成
  • 年・月ごとのアーカイブページ
  • 予約投稿

最後に

Hugoには機能が色々足りなくてこれからも自作していく必要があるけど、コーディングエージェントのおかげで足りないものが一瞬で出来上がるので、そのうちはてなブログより便利になりそうな予感がしている。テーマについても自作したいな。

まだGitHub上やエディタでブログ記事を書くということに慣れていないけど、慣れた環境で記事を書けるのは便利かもしれない。あとリポジトリで管理しておけば生成AIの力を借りて記事を書きやすくなるかも?という気もしている。

Appendix

はてなブログの記事をHugoのMarkdown形式に変換するスクリプト

convert.ts

import fs from 'node:fs';
import path from 'node:path';
import TurndownService from 'turndown';

// --- CLI args ---
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
  console.error('Usage: tsx convert.ts <input-file> --out <output-dir> [--format astro|hugo] [--limit N]');
  process.exit(1);
}

const inputFile = args[0];
let outDir = 'out';
const outIdx = args.indexOf('--out');
if (outIdx !== -1 && args[outIdx + 1]) {
  outDir = args[outIdx + 1];
}
let format: 'astro' | 'hugo' = 'astro';
const fmtIdx = args.indexOf('--format');
if (fmtIdx !== -1 && args[fmtIdx + 1]) {
  const val = args[fmtIdx + 1];
  if (val === 'hugo' || val === 'astro') format = val;
}
let limit = Infinity;
const limitIdx = args.indexOf('--limit');
if (limitIdx !== -1 && args[limitIdx + 1]) {
  limit = parseInt(args[limitIdx + 1], 10);
}

// --- Turndown setup ---
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });

// Strip Hatena keyword links: <a class="keyword" href="...">text</a> -> text
td.addRule('hatena-keyword', {
  filter: (node) =>
    node.nodeName === 'A' &&
    typeof (node as Element).getAttribute === 'function' &&
    (node as Element).getAttribute('class') === 'keyword',
  replacement: (content) => content,
});

// Convert <pre class="code LANG" ...> to fenced code blocks
td.addRule('hatena-pre', {
  filter: (node) =>
    node.nodeName === 'PRE' &&
    typeof (node as Element).getAttribute === 'function' &&
    ((node as Element).getAttribute('class') ?? '').startsWith('code'),
  replacement: (_content, node) => {
    const el = node as Element;
    const cls = el.getAttribute('class') ?? '';
    const langMatch = cls.match(/\bcode\s+(\S+)/);
    const lang = langMatch ? langMatch[1] : '';
    const code = (node as HTMLElement).textContent ?? '';
    return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
  },
});

// --- MT format parser ---
interface Entry {
  title: string;
  basename: string;
  status: string;
  date: string;
  categories: string[];
  body: string;
}

function parseDate(raw: string): string {
  // MM/DD/YYYY HH:MM:SS -> YYYY-MM-DD
  const m = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
  if (!m) return raw;
  return `${m[3]}-${m[1]}-${m[2]}`;
}

function parseDateRFC3339(raw: string): string {
  // MM/DD/YYYY HH:MM:SS -> YYYY-MM-DDTHH:MM:SS+09:00
  const m = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/);
  if (!m) return parseDate(raw);
  return `${m[3]}-${m[1]}-${m[2]}T${m[4]}:${m[5]}:${m[6]}+09:00`;
}

function extractDescription(markdown: string, maxLen = 150): string {
  // Strip markdown syntax, take first non-empty paragraph
  const text = markdown
    .replace(/```[\s\S]*?```/g, '')
    .replace(/[#*`\[\]]/g, '')
    .replace(/\n+/g, ' ')
    .trim();
  return text.slice(0, maxLen).replace(/\s+$/, '');
}

function parseEntries(content: string): Entry[] {
  const rawEntries = content.split(/^--------$/m).map((s) => s.trim()).filter(Boolean);
  const entries: Entry[] = [];

  for (const raw of rawEntries) {
    // Split into header section and body sections by "-----"
    const sections = raw.split(/^-----$/m);
    const header = sections[0];

    const fields: Record<string, string> = {};
    const categories: string[] = [];
    for (const line of header.split('\n')) {
      const match = line.match(/^([A-Z ]+):\s*(.*)/);
      if (match) {
        const key = match[1].trim();
        if (key === 'CATEGORY') {
          categories.push(match[2].trim());
        } else {
          fields[key] = match[2].trim();
        }
      }
    }

    // Find BODY section
    let body = '';
    for (let i = 1; i < sections.length; i++) {
      const sec = sections[i].trim();
      const bodyMatch = sec.match(/^BODY:\n([\s\S]*)/);
      if (bodyMatch) {
        body = bodyMatch[1].trim();
        break;
      }
    }

    entries.push({
      title: fields['TITLE'] ?? '',
      basename: fields['BASENAME'] ?? '',
      status: fields['STATUS'] ?? '',
      date: fields['DATE'] ?? '',
      categories,
      body,
    });
  }

  return entries;
}

function toFrontmatter(entry: Entry, description: string, fmt: 'astro' | 'hugo'): string {
  const title = entry.title.replace(/'/g, "''");
  const desc = description.replace(/'/g, "''");
  const date = parseDate(entry.date);

  if (fmt === 'hugo') {
    const dateRFC3339 = parseDateRFC3339(entry.date);
    const lines = [
      '---',
      `title: '${title}'`,
      `date: '${dateRFC3339}'`,
      `description: '${desc}'`,
      `draft: false`,
    ];
    if (entry.categories.length > 0) {
      const cats = entry.categories.map((c) => `'${c.replace(/'/g, "''")}'`).join(', ');
      lines.push(`categories: [${cats}]`);
      lines.push(`tags: [${cats}]`);
    }
    lines.push('---');
    return lines.join('\n');
  }

  // astro (default)
  const lines = [
    '---',
    `title: '${title}'`,
    `description: '${desc}'`,
    `pubDate: '${date}'`,
  ];
  if (entry.categories.length > 0) {
    const cat = entry.categories[0].replace(/'/g, "''");
    lines.push(`category: '${cat}'`);
  }
  lines.push('---');
  return lines.join('\n');
}

// --- Main ---
const content = fs.readFileSync(inputFile, 'utf-8');
const entries = parseEntries(content);
const published = entries.filter((e) => e.status === 'Publish');

fs.mkdirSync(outDir, { recursive: true });

let count = 0;
for (const entry of published) {
  if (count >= limit) break;
  if (!entry.basename) continue;

  const markdown = td.turndown(entry.body);
  const description = extractDescription(markdown);
  const frontmatter = toFrontmatter(entry, description, format);
  const output = `${frontmatter}\n\n${markdown}\n`;

  const outPath = path.join(outDir, `${entry.basename}.md`);
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, output, 'utf-8');
  count++;
}

console.log(`Converted ${count} posts to ${outDir}/`);