はてなブログで書いていたブログを 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 にも書いていますが、特徴を簡単に書いておきます。

  • はてなブログのすべての記法 (Markdown/はてな記法/見たまま) に対応しています
  • はてな記法で書かれた記事の下記の要素は、それぞれ HTML に変換されます
    • リンク文字列
    • はてなブログカード
    • Twitter 埋め込み
    • はてなフォトライフの画像埋め込み
  • 下書きの記事は draft: true として生成されます
  • Permalink は url: "/entry/..." として生成されます

特に、各記事の 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) でした。
Share to ...
0
Follow on Feedly