概要
2019年に書いたはてなブログの振り返りとして、自分のブログ記事のWordCloudを作ろうと思い立ちました1。
少し調べると、JavaScriptではてなブログのAPIを呼び出している記事を見つけました。
そこでPythonで同様のことを実行してみました。
手を動かして分かったことをこの記事にまとめます。
この記事で扱うスクリプトは以下のことをします。
- 自分が書いたはてなブログの記事の一覧を取得する
- はてなブログに書いた記事をファイルに分けて手元のコンピュータに保存する
説明するトピックは以下の2つです。
- はてなブログのAPIを呼び出すまでの準備
- はてなブログの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のレスポンスにも行いました。
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
文の他に、find
やfindall
メソッドでも扱うことができます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
今回はfind
とfindall
ではてなブログAtomPubのレスポンスを解析しました。
準備2. 認証情報を使ってAPIを呼び出す
Basic認証ではてなブログAtomPubを呼び出すための情報は、はてなブログの 設定 > 詳細設定 > AtomPub にあります。
Basic認証は、requests
のget
メソッドの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.text
をElementTree.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
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 の解析
- 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
を試すことも考えられる
以上です。