PythonからはてなブログのAPIを呼び出して、自分のブログの記事を個別に手元のPCに保存する

## 概要

2019年に書いたはてなブログの振り返りとして、自分のブログ記事のWordCloudを作ろうと思い立ちました[^1]。
少し調べると、[JavaScriptではてなブログのAPIを呼び出している記事](https://korosuke613.hatenablog.com/entry/2019/06/27/223528)を見つけました。
そこでPythonで同様のことを実行してみました。
手を動かして分かったことをこの記事にまとめます。

[^1]: 公開後にリンクを更新します

この記事で扱うスクリプトは以下のことをします。

- 自分が書いたはてなブログの記事の一覧を取得する
- はてなブログに書いた記事をファイルに分けて手元のコンピュータに保存する

説明するトピックは以下の2つです。

1. はてなブログのAPIを呼び出すまでの準備
2. はてなブログのAPIのレスポンス（XML）の解析方法

## 動作環境

```shell
$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.14.4
BuildVersion:	18E226
$ python -V
Python 3.7.3  # venvモジュールで仮想環境を作っています
$ pip list | grep requests
requests    2.22.0
```

## 準備：はてなブログのAPIを呼び出すまで

準備は2つのステップからなります。

- PythonでXMLを解析する方法を知る
- 認証情報を使ってAPIを呼び出す

### そもそも はてなブログのAPIについて

[はてなブログAtomPub | http://developer.hatena.ne.jp/ja/documents/blog/apis/atom](http://developer.hatena.ne.jp/ja/documents/blog/apis/atom)

Atom Publishing Protocol（AtomPub）を用いたAPIが公開されています。

>はてなブログAtomPub を利用するために、クライアントは OAuth 認証、WSSE認証、Basic認証のいずれかを行う必要があります。

今回は**Basic認証**で実装しました。

上記ドキュメント中の「ブログエントリの一覧取得」にあるように、**レスポンスはXML**で返ってきます。

### 準備1. PythonでXMLを解析する方法を知る

標準モジュールの[ElementTree XML API](https://docs.python.org/ja/3/library/xml.etree.elementtree.html)を使いました。

>xml.etree.ElementTree モジュールは、XML データを解析および作成するシンプルかつ効率的な API を実装しています。

単純な例でXMLの操作を確認しましょう。
同様の操作をはてなブログAtomPubのレスポンスにも行いました。

```python:practice.py
import xml.etree.ElementTree as ET

sample_xml_as_string = """<?xml version="1.0"?>
<data>
    <member name="Kumiko">
        <grade>2</grade>
        <instrument>euphonium</instrument>
    </member>
    <member name="Kanade">
        <grade>1</grade>
        <instrument>euphonium</instrument>
    </member>
    <member name="Mizore">
        <grade>3</grade>
        <instrument>oboe</instrument>
    </member>
</data>"""

root = ET.fromstring(sample_xml_as_string)
```

Pythonインタプリタでスクリプトを実行する際に`-i`オプション[^2]を指定します。
この指定により、スクリプトが実行された後に対話モードが立ち上がります（XMLの文字列を対話モードから入力しなくて済むようにする意図です）。

[^2]: ref: https://docs.python.org/ja/3/using/cmdline.html#cmdoption-i

```shell
$ python -i practice.py
>>> root
<Element 'data' at 0x108ac4f48>
>>> root.tag  # <data>タグを表す
'data'
>>> root.attrib  # <data>タグには属性(attribute)はない
{}
>>> for child in root:  # <data>タグの中の入れ子の<member>タグを取り出せる
...     print(child.tag, child.attrib)
...
member {'name': 'Kumiko'}  # memberはnameという属性(attribute)を持つ
member {'name': 'Kanade'}
member {'name': 'Mizore'}
```

XMLのタグの入れ子構造は`for`文の他に、`find`や`findall`メソッドでも扱うことができます[^3]。

[^3]: ref: https://docs.python.org/ja/3/library/xml.etree.elementtree.html#finding-interesting-elements

- `find`: 「特定のタグで 最初の 子要素を検索」
- `findall`: 「タグで現在の要素の直接の子要素のみ検索」

```shell
>>> # 続き
>>> someone = root.find('member')
>>> print(someone.tag, someone.attrib)
member {'name': 'Kumiko'}  # 最初の子要素(member)になっている
>>> members = root.findall('member')
>>> for member in members:
...     print(member.tag, member.attrib)
...
member {'name': 'Kumiko'}  # すべての子要素(member)が取り出されている
member {'name': 'Kanade'}
member {'name': 'Mizore'}
>>> for member in members:
...     instrument = member.find('instrument')
...     print(instrument.text)  # タグで挟まれたテキスト部分
...
euphonium
euphonium
oboe
```

今回は`find`と`findall`ではてなブログAtomPubのレスポンスを解析しました。

### 準備2. 認証情報を使ってAPIを呼び出す

Basic認証ではてなブログAtomPubを呼び出すための情報は、はてなブログの 設定 > 詳細設定 > AtomPub にあります。

<img width="940" alt="hatenablog_atompub_config.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82132/abf4a55e-2558-00c5-bede-03a4f6bbfe45.png">

Basic認証は、`requests`の`get`メソッドの`auth`引数を使いました[^4]。

[^4]: ref: https://2.python-requests.org/en/master/user/authentication/#basic-authentication

```python
blog_entries_url = "https://blog.hatena.ne.jp/nikkie-ftnext/nikkie-ftnext.hatenablog.com/atom/entry"
user_pass_tuple = ("nikkie-ftnext", "the_api_key")
r = requests.get(blog_entries_url, auth=user_pass_tuple)
root = ET.fromstring(r.text)
```

XML形式の文字列`r.text`を`ElementTree.fromstring`メソッドに渡します。

## はてなブログのAPIのレスポンスの解析

実際のレスポンスとhttp://developer.hatena.ne.jp/ja/documents/blog/apis/atom の「ブログエントリの一覧取得」を見比べつつ、XMLの解析を進めました。

```python
In [20]: for child in root:  # rootは上述の例と同じ
    ...:     print(child.tag, child.attrib)  
    ...:                                                                        
{http://www.w3.org/2005/Atom}link {'rel': 'first', 'href': 'https://blog.hatena.ne.jp/nikkie-ftnext/nikkie-ftnext.hatenablog.com/atom/entry'} 
{http://www.w3.org/2005/Atom}link {'rel': 'next', 'href': 'https://blog.hatena.ne.jp/nikkie-ftnext/nikkie-ftnext.hatenablog.com/atom/entry?page=1572227492'} # 記事一覧の次のページ
{http://www.w3.org/2005/Atom}title {} 
{http://www.w3.org/2005/Atom}subtitle {} 
{http://www.w3.org/2005/Atom}link {'rel': 'alternate', 'href': 'https://nikkie-ftnext.hatenablog.com/'} 
{http://www.w3.org/2005/Atom}updated {} 
{http://www.w3.org/2005/Atom}author {} 
{http://www.w3.org/2005/Atom}generator {'uri': 'https://blog.hatena.ne.jp/', 'version': '3977fa1b6c9f31b5eab4610099c62851'} 
{http://www.w3.org/2005/Atom}id {} 
{http://www.w3.org/2005/Atom}entry {} # 個々の記事
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
{http://www.w3.org/2005/Atom}entry {} 
```

個々の記事（tagがentryの要素）も見ていきます。

```python
>>> for item in child:  # childにはある記事が入っている（forで回した残り）
...     print(item.tag, item.attrib)
...
{http://www.w3.org/2005/Atom}id {}
{http://www.w3.org/2005/Atom}link {'rel': 'edit', 'href': 'https://blog.hatena.ne.jp/nikkie-ftnext/nikkie-ftnext.hatenablog.com/atom/entry/26006613457877510'}
{http://www.w3.org/2005/Atom}link {'rel': 'alternate', 'type': 'text/html', 'href': 'https://nikkie-ftnext.hatenablog.com/entry/rejectpy2019-plan-step0'}
{http://www.w3.org/2005/Atom}author {}
{http://www.w3.org/2005/Atom}title {}  # タイトル（WordCloudの対象とする）
{http://www.w3.org/2005/Atom}updated {}
{http://www.w3.org/2005/Atom}published {}  # 注意：ドラフトでも格納される
{http://www.w3.org/2007/app}edited {}
{http://www.w3.org/2005/Atom}summary {'type': 'text'}
{http://www.w3.org/2005/Atom}content {'type': 'text/x-markdown'}  # 本文
{http://www.hatena.ne.jp/info/xmlns#}formatted-content {'type': 'text/html'}
{http://www.w3.org/2005/Atom}category {'term': '登壇報告'}
{http://www.w3.org/2007/app}control {}  # 子要素にdraftか否かの情報を持つ
```

WordCloudは公開した記事から作りたいので、`{http://www.w3.org/2007/app}control`の子要素のdraftを確認します（yesであれば未公開のドラフトなので対象外）。

WordCloudに使うテキストは、タイトルと本文をつなげて用いました。

## コード全容

以下のリポジトリで公開しています：
https://github.com/ftnext/hatenablog-atompub-python

```python:main.py
import argparse
from datetime import datetime, timedelta, timezone
import os
from pathlib import Path
import xml.etree.ElementTree as ET

import requests


def load_credentials(username):
    """はてなAPIアクセスに必要な認証情報をタプルの形式で返す"""
    auth_token = os.getenv("HATENA_BLOG_ATOMPUB_KEY")
    message = "環境変数`HATENA_BLOG_ATOMPUB_KEY`にAtomPubのAPIキーを設定してください"
    assert auth_token, message
    return (username, auth_token)


def retrieve_hatena_blog_entries(blog_entries_uri, user_pass_tuple):
    """はてなブログAPIにGETアクセスし、記事一覧を表すXMLを文字列で返す"""
    r = requests.get(blog_entries_uri, auth=user_pass_tuple)
    return r.text


def select_elements_of_tag(xml_root, tag):
    """返り値のXMLを解析し、指定したタグを持つ子要素をすべて返す"""
    return xml_root.findall(tag)


def return_next_entry_list_uri(links):
    """続くブログ記事一覧のエンドポイントを返す"""
    for link in links:
        if link.attrib["rel"] == "next":
            return link.attrib["href"]


def is_draft(entry):
    """ブログ記事がドラフトかどうか判定する"""
    draft_status = (
        entry.find("{http://www.w3.org/2007/app}control")
        .find("{http://www.w3.org/2007/app}draft")
        .text
    )
    return draft_status == "yes"


def return_published_date(entry):
    """ブログ記事の公開日を返す

    ドラフトの場合も返される仕様だった
    """
    publish_date_str = entry.find(
        "{http://www.w3.org/2005/Atom}published"
    ).text
    return datetime.fromisoformat(publish_date_str)


def is_in_period(datetime_, start, end):
    """指定した日時がstartからendまでの期間に含まれるか判定する"""
    return start <= datetime_ < end


def return_id(entry):
    """ブログのURIに含まれるID部分を返す"""
    link = entry.find("{http://www.w3.org/2005/Atom}link")
    uri = link.attrib["href"]
    return uri.split("/")[-1]


def return_contents(entry):
    """ブログのタイトルと本文をつなげて返す"""
    title = entry.find("{http://www.w3.org/2005/Atom}title").text
    content = entry.find("{http://www.w3.org/2005/Atom}content").text
    return f"{title}。\n\n{content}"


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("hatena_id")
    parser.add_argument("blog_domain")
    parser.add_argument("target_year", type=int)
    parser.add_argument("--output", type=Path)
    args = parser.parse_args()

    hatena_id = args.hatena_id
    blog_domain = args.blog_domain
    target_year = args.target_year
    output_path = args.output if args.output else Path("output")

    user_pass_tuple = load_credentials(hatena_id)

    blog_entries_uri = (
        f"https://blog.hatena.ne.jp/{hatena_id}/{blog_domain}/atom/entry"
    )

    jst_tz = timezone(timedelta(seconds=9 * 60 * 60))
    date_range_start = datetime(target_year, 1, 1, tzinfo=jst_tz)
    date_range_end = datetime(target_year + 1, 1, 1, tzinfo=jst_tz)

    oldest_published_date = datetime.now(jst_tz)
    target_entries = []

    while date_range_start <= oldest_published_date:
        entries_xml = retrieve_hatena_blog_entries(
            blog_entries_uri, user_pass_tuple
        )
        root = ET.fromstring(entries_xml)

        links = select_elements_of_tag(
            root, "{http://www.w3.org/2005/Atom}link"
        )
        blog_entries_uri = return_next_entry_list_uri(links)

        entries = select_elements_of_tag(
            root, "{http://www.w3.org/2005/Atom}entry"
        )
        for entry in entries:
            if is_draft(entry):
                continue
            oldest_published_date = return_published_date(entry)
            if is_in_period(
                oldest_published_date, date_range_start, date_range_end
            ):
                target_entries.append(entry)
        print(f"{oldest_published_date}までの記事を取得（全{len(target_entries)}件）")

    output_path.mkdir(parents=True, exist_ok=True)

    for entry in target_entries:
        id_ = return_id(entry)
        file_path = output_path / f"{id_}.txt"
        contents = return_contents(entry)
        with open(file_path, "w") as fout:
            fout.write(contents)
```

### 実行例

```shell
$ python main.py nikkie-ftnext nikkie-ftnext.hatenablog.com 2019 --output output/2019
2019-10-30 11:25:23+09:00までの記事を取得（全9件）
2019-06-13 10:18:36+09:00までの記事を取得（全18件）
2019-03-30 13:52:19+09:00までの記事を取得（全27件）
2018-12-23 10:24:06+09:00までの記事を取得（全32件）
# -> output/2019の下にテキストファイルが32個作られる（中身は自分の書いたブログ）
```

こうして、はてなブログAtomPubから自分の書いたブログ記事が取得できました！

## 申し送り

同様のことを繰り返す際は、以下について調査したり取り入れたりしたいと思っています。

- AtomPubからは自分のブログだけしか取得できなさそう
	- 他の人のブログも対象としたい場合はおそらくブログメンバーになる必要があると思われる
	- もしくは robots.txt を確認した上でスクレイピング？
- 名前空間のある XML の解析
	- https://docs.python.org/ja/3/library/xml.etree.elementtree.html#parsing-xml-with-namespaces
	- `{http://www.w3.org/2005/Atom}link`のような指定をしなくてもよかった（ドキュメントにあるようにDRYに書けた）
- XMLの解析に[`beautifulsoup4`](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)を試すことも考えられる

以上です。