2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

概要

2019年に書いたはてなブログの振り返りとして、自分のブログ記事のWordCloudを作ろうと思い立ちました1
少し調べると、JavaScriptではてなブログのAPIを呼び出している記事を見つけました。
そこでPythonで同様のことを実行してみました。
手を動かして分かったことをこの記事にまとめます。

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

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

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

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

動作環境

$ 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

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

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

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

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

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

標準モジュールのElementTree XML APIを使いました。

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

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

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の文字列を対話モードから入力しなくて済むようにする意図です)。

$ 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文の他に、findfindallメソッドでも扱うことができます3

  • find: 「特定のタグで 最初の 子要素を検索」
  • findall: 「タグで現在の要素の直接の子要素のみ検索」
>>> # 続き
>>> 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

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

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

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

hatenablog_atompub_config.png

Basic認証は、requestsgetメソッドのauth引数を使いました4

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.textElementTree.fromstringメソッドに渡します。

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

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

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の要素)も見ていきます。

>>> 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

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)

実行例

$ 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 の解析
  • XMLの解析にbeautifulsoup4を試すことも考えられる

以上です。

  1. 公開後にリンクを更新します

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

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

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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?