michimani.net

はてなブログの記事を Hugo に移行するツールを作ってみた

2020-12-22

はてなブログで書いていたブログを Hugo で作り直したいと思い、 Go の勉強も兼ねて簡単な移行ツールを作ってみました。

目次

概要

はてなブログに投稿していた記事を Hugo 用の Markdown ファイルとしてエクスポートするツールを作りました。

経緯

このブログとは別に、趣味のバイクに関する記事をはてなブログで書いています。(最後の更新は一年以上前ですが…)
はてなブログに移行する前は WordPress で書いていたため、独自ドメインをそのまま使うためにはてなブログ Pro を契約して使っています。そのはてなブログ Pro の更新が来年の 1月末ということで、それまでに移行したいなと思っていたのが経緯です。

AtomPub を利用して記事を参照する

はてなブログには AtomPub を利用して記事の参照、投稿、編集、削除を行うことができます。

AtomPub を利用するには OAuth 認証、WSSE認証、Basic認証 のいずれかの方法で認証を行う必要がありますが、今回は Basic 認証で利用してみます。 Basic 認証の ID は はてなID で、 パスワードは API キー を使用します。この API キー については、はてなブログ管理画面の 設定 > 詳細設定AtomPub APIキー で確認することができます。

AtomPub の詳しい仕様については下記ページに書かれているので、ここでは記事の参照についてのみ触れます。

参照用エンドポイントにアクセスして XML を取得

記事一覧を参照するには、下記のエンドポイントにアクセスします。

https://blog.hatena.ne.jp/{はてなID}/{ブログID}/atom/entry/

このエンドポイントについては、はてなブログ管理画面の 設定 > 詳細設定 にある AtomPub ルートエンドポイント で確認できます。
レスポンスは ContentType: application/atom+xml で返却されます。

今回は Basic 認証を用いてアクセスするので、 Go では次のようにして XML を取得します。(import 文は省略してます)

func main() {
	xmlData, err := getXML(next)
	if err != nil {
		fmt.Println(err)
		break
	}
}

func getXML(url string) (string, error) {
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return "", err
	}

	var basicAuthZ string = generateBasicAuthZ()
	req.Header.Set("Authorization", "Basic "+basicAuthZ)
	client := new(http.Client)
	res, err := client.Do(req)
	if err != nil {
		return "", err
	}

	if res.StatusCode != 200 {
		return "", fmt.Errorf("Failed to get xml. [%s]", res.Status)
	}

	body, _ := ioutil.ReadAll(res.Body)
	defer res.Body.Close()
	return string(body), nil
}

func generateBasicAuthZ() string {
	raw := hatenaId + ":" + hatenaAPIKey
	bytes := []byte(raw)
	return base64.StdEncoding.EncodeToString(bytes)
}

取得できる XML の内容は下記のようになっています。

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app">
  <link rel="first" href="{参照用のルートエンドポイント}" />
  <link rel="next" href="{次のページの参照用エンドポイント}" />
  <title>{ブログタイトル}</title>
  <subtitle>{ブログのサブタイトル}</subtitle>
  <link rel="alternate" href="{ブログの URL}"/>
  <updated>2019-06-14T17:41:59+09:00</updated>
  <author>
    <name>{はてな ID}</name>
  </author>
  <generator uri="https://blog.hatena.ne.jp/" version="4d0fe3f3cd3000000000020000000000">Hatena::Blog</generator>
  <id>hatenablog://blog/00000000000000000000</id>
  <entry>
    ...
  </entry>
  <entry>
    ...
  </entry>
</feed>

また、 <entry> の中は下記のようになっています。

<entry>
  <id>tag:blog.hatena.ne.jp,2013:blog-{はてな ID}-00000000000000000000-00000000000000000</id>
  <link rel="edit" href="{個別記事を参照するエンドポイント}"/>
  <link rel="alternate" type="text/html" href="{記事の URL}"/>
  <author><name>{はてな ID}</name></author>
  <title>{記事タイトル}</title>
  <updated>2020-12-22T00:03:46+09:00</updated>
  <published>2020-12-21T21:00:58+09:00</published>
  <app:edited>2020-12-22T00:03:46+09:00</app:edited>
  <summary type="text">{記事の概要}</summary>
  <content type="text/x-markdown">
  ...
  </content>
  <hatena:formatted-content type="text/html" xmlns:hatena="http://www.hatena.ne.jp/info/xmlns#">
  ...
  </hatena:formatted-content>

  <category term="{カテゴリ名1}" />
  <category term="{カテゴリ名2}" />
  <app:control>
    <app:draft>yes</app:draft>
  </app:control>
</entry>

これを Go の構造体に落とし込みます。

Go で XML をパースする

今回は XML をパースするために、 encoding/xml パッケージを使用します。

パースする前に、 XML の構造に合わせて次のような構造体を定義します。

type Atom struct {
	Title    string  `xml:"title"`
	SubTitle string  `xml:"subtitle"`
	Links    []Link  `xml:"link"`
	Entries  []Entry `xml:"entry"`
}

type Entry struct {
	Links          []Link     `xml:"link"`
	Author         string     `xml:"author>name"`
	Title          string     `xml:"title"`
	Published      string     `xml:"updated"`
	Content        string     `xml:"content"`
	Summary        string     `xml:"summary"`
	Draft          string     `xml:"control>draft"`
	Categories     []Category `xml:"category"`
}

type Category struct {
	Term string `xml:"term,attr"`
}

type Link struct {
	Rel  string `xml:"rel,attr"`
	Href string `xml:"href,attr"`
}

xml:"title" のようにタグを指定することで、 XML 内の要素と関連付けることができます。また、 xml:"term,attr" のようにタグを指定することで、 XML の各要素の属性と関連付けることができます。

実際にパースする部分は下記のとおりです。

func main() {
	// ...

	atom := Atom{}
	xerr := xml.Unmarshal([]byte(xmlData), &atom)
	if xerr != nil {
		fmt.Println(xerr.Error())
		break
	}
	
	fmt.Println(atom.Title)
	fmt.Println(atom.SubTitle)
	fmt.Println("-----")

	for _, entry := range atom.Entries {
		fmt.Println(entry.Title)
	}
}

上記のような実装で、 XML の情報を構造体に落とし込むことができました。

みちのえきまにあ
SC59 CBR1000RRで道の駅巡りをしているまったりツーリングライダーのブログ
-----
test
泊まりのツーリングで初めて車体トラブルが発生した話
SSTR 2019 に参加してきました!
.
.
.

あとは、これらのデータをゴニョゴニョやって、下記のような Hugo 用の Markdown ファイルを生成します。

---
title: "%s"
date: %s
draft: %s
author: ["%s"]
categories: [%s]
archives: ["%s", "%s"]
description: "%s"
url: "/%s"
---

%s

完成したもの

特徴

README にも書いていますが、特徴を簡単に書いておきます。

特に、各記事の URL を Permalink として生成しておくことで、 URL をそのまま引き継ぐことができるようになってます。

ただし注意点としては、Markdown ファイルとして生成するものの、中身は純粋な Markdown ではなく HTML も混じった状態で生成されます。そのため、 Hugo の設定ファイル config.toml に下記の記述を追加し、記事内で HTML 及び JavaScript のコードを生で記述しても解釈されるようにする必要があります。

[markup]
    [markup.goldmark]
        [markup.goldmark.renderer]
            unsafe = true

また、上記のはてな記法以外のタグ (Amazon の商品リンクなど) は変換されないので、手で直す必要があります。(今後対応するかもしれません)

使い方

使い方は簡単で、上のリポジトリを clone してもらって docker build して docker run するだけです。

詳しくは README を読んでください。

まとめ

はてなブログで書いた記事を Hugo に移行してみた話でした。
Go の勉強も兼ねて作ってみたツールなので、書き方おかしいとかコード汚いとか全然動かないとかあれば issue、PR いただけると嬉しいです。

以上、よっしー (michimani) でした。