tech.guitarrapc.cóm

Technical updates

はてなブログのみたままモード記事をMarkdownモード記事へ変換する

このブログの古い記事はWordPressからはてなブログにインポートしたもので、当時はHTMLを直書きしていました。 HTML直書きははてなブログの「みたままモード」に相当し、これらの記事はマークダウンなのにHTML構文が溢れてtextlintも効かない状況でした。

先日これらの記事をMarkdownモードにまとめて変換したのでメモです。

方針

はてなブログ開発ブログによると、みたままモードの記事からMardownモードへの変換をサポートしていません。このため、みたままモードの記事を一度消してから、同じ日付でMarkdownモードの記事を新たに作成する必要があります。

はてなブログの編集モードは変換できない

私の場合、対象記事が200件以上あったので、手動で消して新たに作成するのは大変です。そこで、ヘルパーコードを用意して以下の手順で変換しました。

  1. 既存コンテンツをバックアップ
  2. 対象記事の日付一覧を取得
  3. はてなブログの下書きをまとめて作成
  4. はてなブログのHTML記事をマークダウンフォーマットに変換コピー
  5. マークダウン変換時の漏れを修正
  6. 既存の記事を削除
  7. マークダウン記事を公開 (3-7を全記事終わるまで繰り返し)
  8. 後始末

順に説明します。

1.既存記事をバックアップ

この処理の実行前に、元記事にはCustomPathを設定しています。CustomPathは、記事ファイルの配置パスに相当するyyyy/MM/dd/HHmmssを指定するメタデータです。

既存のコンテンツをバックアップします。次のC#ヘルパーコードを利用して、HTMLの元記事を記事名_2.mdのように末尾に_2を付けてリネームして退避します。作業用に元のファイル名へコンテンツをコピーして、メタデータにあるCustomPathの情報も書き換えてます。

// 既存コンテンツをバックアップする処理。
// 既存コンテンツを退避して、マークダウン変換前の作業ファイルと元ファイルをそれぞれ用意する
var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};

Rename(basePath, targetMonths, "_2");
Copy(basePath, targetMonths, "_2".Length);
RenameCustomPath(basePath, targetMonths, "_2");

static void Rename(string basePath, string[] targetMonths, string suffix)
{
    foreach (var path in targetMonths)
    {
        var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            var dst = Path.Combine(Path.GetDirectoryName(file), Path.GetFileNameWithoutExtension(file) + suffix + Path.GetExtension(file));
            dst.Dump(file);
            if (File.Exists(dst))
                continue;
            File.Move(file, dst);
        }
    }
}

static void Copy(string basePath, string[] targetMonths, int removeSuffixLetters)
{
    foreach (var path in targetMonths)
    {
        var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            var name = Path.GetFileNameWithoutExtension(file);
            var dst = Path.Combine(Path.GetDirectoryName(file), name.Substring(0, name.Length - removeSuffixLetters)  + Path.GetExtension(file));
            dst.Dump(file);
            if (File.Exists(dst))
                continue;
            File.Copy(file, dst);
        }
    }
}

static void RenameCustomPath(string basePath, string[] targetMonths, string excludeSuffix)
{
    foreach (var path in targetMonths)
    {
        var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            var name = Path.GetFileNameWithoutExtension(file);
            if (name.EndsWith(excludeSuffix))
                continue;
            var customPath = file.Substring(basePath.Length, file.Length - basePath.Length - Path.GetExtension(file).Length).Replace("\\", "/");
            file.Dump(customPath);
            var content = File.ReadAllText(file);
            var newContent = content.Replace($"CustomPath: {customPath}_2", $"CustomPath: {customPath}");
            File.WriteAllText(file, newContent);
        }
    }
}

2.対象記事の日付一覧を取得

HTML直書きの対象記事だけ変換すればいいので、記事の対象日付を取得します。 月・日付が分かっていて手元に記事一覧があるので、記事のパスから取得することにしました。C#でヘルパーツールを書いて作っと実行します。

var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};

foreach (var path in targetMonths)
{
    var files = Directory.EnumerateFiles(Path.Combine(basePath, path), "*.md", SearchOption.AllDirectories);
    foreach (var file in files.Where(x => !Path.GetFileNameWithoutExtension(x).EndsWith("_2")))
    {
        var name = file.Replace(basePath, "").Replace("\\", "/").Replace(".md", "");
        Console.WriteLine($"- {name}");
    }
}

実行すると次のような結果が得られます。

- 2012/11/09/101113
- 2012/11/09/211115
- 2012/11/13/001154
- 2012/11/13/221115
- 2012/11/14/071151
- 2012/12/11/231250
- 2012/12/18/221226
- 2012/12/19/001244
- 2012/12/20/161249
- 2012/12/20/211206
- 2012/12/25/201225
- 2012/12/25/201230
- 2012/12/26/121207
- 2012/12/31/141220
- 2013/01/06/080136
- 2013/01/08/030100
- 2013/01/15/050140
- 2013/01/19/210114

3.はてなブログの下書きをまとめて作成

はてなが提供するワークフローcreate-draft.yamlはworkflow_dispatchでタイトルを指定して実行すると、下書きを生成します。これをいじって、対象の日付をマトリックスで指定してまとめて下書き記事を作成するcreate-draft-bulk.yamlワークフローを用意します。先ほど取得した日付をマトリックスに指定して実行します。

name: create draft (bulk)

on:
  workflow_dispatch:

jobs:
  create-draft:
    strategy:
      max-parallel: 5
      matrix:
        title:
          # 作成する対象の記事日付を指定
          - 2012/11/09/101113
          - 2012/11/09/211115
          - 2012/11/13/001154
          - 2012/11/13/221115
          - 2012/11/14/071151
          - 2012/12/11/231250
          - 2012/12/18/221226
          - 2012/12/19/001244
          - 2012/12/20/161249
          - 2012/12/20/211206
          - 2012/12/25/201225
          - 2012/12/25/201230
          - 2012/12/26/121207
          - 2012/12/31/141220
    uses: ./.github/workflows/_create-draft.yaml
    with:
      title: ${{ matrix.title }}
      draft: true
      BLOG_DOMAIN: ${{ vars.BLOG_DOMAIN }}
    secrets:
      OWNER_API_KEY: ${{ secrets.OWNER_API_KEY }}

_create-draft.yamlは、もともとのはてなブログのcreate-draft-pull-requestアクションがgithub.inputs.titleを使っていてアクションで指定したtitleを使ってなかったのを修正したものです。現在はPRを投げて修正されています。

下書き記事を作成を1ブランチにまとめる

create-branchアクションは、1下書きあたり1PRを作ります。PRマージ作業がつらいので、PRを1ブランチにまとめてマージできるようにします。

1ブランチにまとめたら下書き用のブランチは削除しておきます。私は次のようなシェルスクリプトを作成してリモートブランチを削除しました。

#!/bin/bash
set -eo pipefail

repo=guitarrapc/blog
for branch in $(gh api "repos/$repo/branches" --jq '.[].name' | grep '^draft-entry-'); do
  echo "Deleting remote branch: $branch"
  gh api --method DELETE -H "Accept: application/vnd.github+json" "/repos/$repo/git/refs/heads/$branch"
done

実行すると次のようにリモートブランチが削除されます。

Deleting remote branch: draft-entry-6802418398340967690
Deleting remote branch: draft-entry-6802418398340967692
Deleting remote branch: draft-entry-6802418398340967694
Deleting remote branch: draft-entry-6802418398340967696
Deleting remote branch: draft-entry-6802418398341016588
Deleting remote branch: draft-entry-6802418398341016611
Deleting remote branch: draft-entry-6802418398341016620

はてなブログのAtomPubレートリミットに注意

はてなブログのAtomPubには100件/24hまでしか記事を作成できないAPIレートリミットがあるので注意してください。 APIレートリミットに到達するとEntry limit was exceededというエラーが帰ってきます。

AtomPubのAPIリミット

私は下書き記事を月ごとに作成したのですが、一日に作業できる記事的に3日かかりました。レートリミットに引っかかって感じたんですが、1日100件の制限はドキュメントに記載がなく、残り何件とか分からないのは残念です。 特に、レートリミットがあるのにAPIドキュメントに書かないのは修正してほしいです。

4.はてなブログのHTML記事をマークダウンフォーマットに変換コピー

作成した下書き記事に、HTML記事のコンテンツをマークダウンフォーマットに変換しつつコピーします。C#でヘルパーツールを書いて作成しました。この処理は下書き記事が存在しないとコピーしないので、作業したい下書き記事を作成した後に実行します。

// 既存コンテンツをベースにDraftにコンテンツを持ってくる処理。
// 既存コンテンツがHTMLなのをマークダウンに変換する機能をはてなブログはもっていないので、同一URLで記事を新規で作り直すために行う。
// Steps to use:
// 1. 既存の記事にCustomPath: yyyy/MM/dd/HHmmss を設定
// 2. create-draft-bulk ワークフローでyyyy/MM/ddの下書きをまとめて作成
// 3. ドラフトのブランチを1ブランチにまとめて、複数日まとめて処理する
// 4. このスクリプトを実行して、既存コンテンツをベースに下書きを更新 <- イマココ
var draftBasePath = @"D:\github\guitarrapc\blog\draft_entries\";
var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};
var titlePattern = new Regex("201[2,3]/[0-9]{2}/[0-9]{2}/[0-9]{6}", RegexOptions.Compiled);

var drafts = Directory.EnumerateFiles(draftBasePath, "*.md");
foreach (var draft in drafts)
{
    var title = File.ReadLines(draft)
        .Where(x => x.StartsWith("Title:"))
        .Select(x => x.Split(":")[1].Trim())
        .Single();

    if (!titlePattern.IsMatch(title))
        continue;

    var draftContent = File.ReadAllLines(draft);
    var draftEditUrlLine = draftContent.Where(x => x.StartsWith("EditURL: ")).Single();
    var draftPreviewURL = draftContent.Where(x => x.StartsWith("PreviewURL: ")).Single();

    foreach (var targetMonth in targetMonths)
    {
        var searchBase = Path.Combine(basePath, targetMonth);
        var files = Directory.EnumerateFiles(searchBase, "*.md", SearchOption.AllDirectories);
        foreach (var file in files)
        {
            // 既存コンテンツか判定
            var lines = File.ReadAllLines(file);
            var isTargetFile = lines.Any(x => x.Contains($"CustomPath: {title}"));
            if (!isTargetFile)
                continue;

            // _2.md は除外
            if (Path.GetFileNameWithoutExtension(file).EndsWith("_2"))
            {
                File.Delete(file);
                continue;
            }

            // 既存コンテンツをベースにdraftにコンテンツを持ってくる
            var sectionLines = GetHeaderSectionLines(lines);
            var titleLine = sectionLines.Where(x => x.StartsWith("Title: ")).Single();
            var categoryLine = GetCategories(sectionLines);
            var dateLine = sectionLines.Where(x => x.StartsWith("Date: ")).Single();
            var urlLine = sectionLines.Where(x => x.StartsWith("URL: ")).Single();
            var customPathLine = sectionLines.Where(x => x.StartsWith("CustomPath: ")).Single();

            var contentBuilder = new StringBuilder();
            // ヘッダー
            contentBuilder.AppendLine("---");
            contentBuilder.AppendLine(titleLine);
            contentBuilder.AppendLine(categoryLine);
            contentBuilder.AppendLine(dateLine);
            contentBuilder.AppendLine(draftEditUrlLine);
            contentBuilder.AppendLine(draftPreviewURL);
            contentBuilder.AppendLine(customPathLine);
            contentBuilder.AppendLine("---");

            // コンテンツ
            var contentsLines = lines.Skip(sectionLines.Length);
            contentBuilder.AppendLine();
            contentBuilder.AppendLine("<!--");
            contentBuilder.AppendLine($"{dateLine}");
            contentBuilder.AppendLine($"{urlLine}");
            contentBuilder.AppendLine("-->");
            foreach (var line in contentsLines)
            {
                contentBuilder.AppendLine(line);
            }
            var content = contentBuilder.ToString();

            // 書き込み
            if (content != "")
            {
                File.WriteAllText(draft, content);
            }

            // 既存コンテンツを削除する
            File.Delete(file);
        }
    }
}

static string[] GetHeaderSectionLines(string[] lines)
{
    int number = 0;
    var inSection = false;
    foreach (var line in lines)
    {
        // enter
        if (!inSection && line == "---")
        {
            inSection = true;
            number++;
            continue;
        }
        // exit
        if (inSection && line == "---")
        {
            inSection = false;
            number++;
            break;
        }
        // inside
        if (inSection && line != "---")
        {
            number++;
            continue;
        }
    }

    return lines.Take(number).ToArray();
}

static string GetCategories(string[] lines)
{
    var categoryLines = new StringBuilder();
    var inCategory = false;
    foreach (var line in lines)
    {
        if (line.StartsWith("Category:"))
        {
            inCategory = true;
            categoryLines.AppendLine(line);
            continue;
        }
        if (inCategory)
        {
            if (!line.StartsWith("- "))
            {
                inCategory = false;
                break;
            }

            categoryLines.AppendLine(line);
            continue;
        }
    }

    // 最終行の空行はトリム除去
    return categoryLines.ToString().TrimEnd();
}

作業後、あとから記事を識別できるように次のようなHTMLコメントを残してあります。

<!--
Date: 2012-12-20T21:12:06+09:00
URL: https://tech.guitarrapc.com/entry/2012/12/20/211206
-->

5.マークダウン変換時の漏れを修正

C#コード上である程度のマークダウン変換しているのですが、HTMLでいろいろなタグを使っていたので網羅しきれません。マークダウン変換した下書き記事をはてなブログで見て変換漏れを修正します。

世の中にはHTMLをマークダウンに変換するツールがたくさんあるので、それをつかっても良かったですね。残念ながら記事分量が多すぎてLLMで変換もできなかったので、手作業で修正しました。私は次の修正が多かったです。

  • <a>タグの属性指定ばらつき
  • <pre>タグの属性指定ばらつき
  • 文中の<code>タグの変換
  • URLをはてなブログURLに変換
  • リンク切れ対処
  • 画像のリンク切れ修正

加えてtextlintを使ってマークダウンの文法チェックを行いました。これもかなり手間でした。

6.既存の記事を削除

既存のHTML記事を削除します。これははてなブログの管理画面から作っと消しましょう。

7.マークダウン記事を公開

下書きPRをマージして、マークダウン記事を公開します。PRをマージすると、CustomPathにしたがってファイルが正しいフォルダに配置されます。

あとはすべてのHTML記事をマークダウン記事に置き換えるまで繰り返します。

8.後始末

バックアップしておいてHTML元記事を削除して置きます。以上の行程で、同一URLで記事の置き換え完了です。

あとは時間がある時に、マークダウンファイルからHTMLコメントを削除します。次のC#コードを実行すればOKです。

// マージ後に記事から以下のセクションを抜く処理
/*
<!--
Date: 2012-12-11T23:12:50+09:00
URL: https://tech.guitarrapc.com/entry/2012/12/11/231250
-->
*/
var basePath = @"D:\github\guitarrapc\blog\entries\guitarrapc-tech.hatenablog.com\entry\";
var targetMonths = new[] {
    "2012/11",
    "2012/12",
    "2013/01",
    "2013/02",
    "2013/03",
    "2013/04",
    "2013/05",
    "2013/06",
    "2013/07",
    "2013/08",
    "2013/09/02",
    "2013/09/03",
    "2013/09/06",
    "2013/09/07",
    "2013/09/08",
    "2013/09/10",
};

foreach (var targetMonth in targetMonths)
{
    var searchBase = Path.Combine(basePath, targetMonth);
    var files = Directory.EnumerateFiles(searchBase, "*.md", SearchOption.AllDirectories);
    foreach (var file in files)
    {
        // _2.md は除外
        if (Path.GetFileNameWithoutExtension(file).EndsWith("_2"))
            continue;

        var lines = File.ReadAllLines(file);
        var contentBuilder = new StringBuilder();
        var inSkip = false;
        foreach (var line in lines)
        {
            if (line.StartsWith("<!--"))
            {
                inSkip = true;
                continue;
            }
            if (inSkip && line.StartsWith("-->"))
            {
                inSkip = false;
                continue;
            }
            if (inSkip && line.StartsWith("Date: "))
                continue;
            if (inSkip && line.StartsWith("URL: "))
                continue;

            contentBuilder.AppendLine(line);
        }

        var content = contentBuilder.ToString();
        File.WriteAllText(file, content);
    }
}

まとめ

はてなブログがマークダウン変換サポートしてくれれば簡単でしたがしょうがない。 LinqPadが作業のお供でした。C#で適当にサポートツールを作成するの、割と楽なんですよね。

参考