2021年2月14日

PythonからはてなフォトライフAPIを使って画像をアップロードする

 今日の地震は久々に大きかったですね。。

その時に書いてたコードが今回のお題なんですが、今や懐かしいOAuth1認証や公式ドキュメントに記載されていない罠などを越えていきながらなんとかできたので記録しておこうと思います。

はてなにアプリケーションを登録

まず初めに、Hatena Developer Centerのページにある【アプリケーション登録】ボタンよりAPIを使うためのアプリケーション登録を行って、OAuth Consumer KeyとOAuth Consumer Secretを取得します。

その際、「承認を求める操作」いわゆる権限には、アップロード操作に必要なwrite_private権限が必要となるため、そちらにチェックしておきます。
「アプリケーションの名称」や「アプリケーションのURL」は、OOB(out-of-band)で取得する場合は分かりやすいものを設定しておけばOKです。

検証(PIN)コード経由による認可とアクセストークンの取得

実装に入る前に、OAuth1認証によるAPI接続を行うためpython-oauthlibライブラリーをインストールしておきます。
python3 -m pip install python-oauthlib
import文は以下を利用しています。また、コード中ではf-stringを使用しているため、Python3.6以上が必要です。
(f''の部分を''.format()に書き換えれば3.6未満でも動作可能)
from oauthlib.oauth1.rfc5849 import Client, CONTENT_TYPE_FORM_URLENCODED
import base64
import io
import os
import urllib
import webbrowser
実装では本ライブラリーを利用して以下の手順で処理を進めてアクセストークンを取得します。
  1. Temporary Credential Request URLに接続してクライアントクレデンシャルを取得
  2. 取得したクライアントクレデンシャルをResource Owner Authorization URLに渡してブラウザー上で認可確認ページに接続
  3. 認可が完了した際に払い出される検証(PIN)コードをToken Request URLに渡してアクセストークンを取得
最初は、はてなでアプリケーション登録時に払い出されたOAuth Consumer KeyとOAuth Consumer Secretを使い、認可ページに進むためのクライアントクレデンシャルを取得します。
init_client = Client(consumer_key, client_secret=consumer_secret, callback_uri='oob')
init_url, init_headers, init_body = init_client.sign(
    'https://www.hatena.com/oauth/initiate',
    http_method='POST',
    headers={'Content-Type': CONTENT_TYPE_FORM_URLENCODED},
    body='scope=read_public,read_private,write_public,write_private',
req = urllib.request.Request(init_url, method='POST', headers=init_headers, data=init_body.encode())
with urllib.request.urlopen(req) as res:
    d = dict(urllib.parse.parse_qsl(res.read().decode()))
    oauth_token = d["oauth_token"]
    oauth_token_secret = d["oauth_token_secret"]
    webbrowser.open(f'https://www.hatena.ne.jp/oauth/authorize?oauth_token={oauth_token}')
bodyのscopeには、アプリケーション登録時に指定した権限を文字列で列挙します。
webbrowser.open()を使ってブラウザーを起動していますが、ラズパイ上など画面が無い環境で動かす場合はこのURLを手元のブラウザーにコピペします。

ブラウザー上では、scopeで指定された権限をアプリに許可していいか確認するページが表示され、ここで許可すると検証(PIN)コードが表示されます。
(callback_urlに'oob'ではなくURLを指定すると、そのURLに検証(PIN)コードが自動的に渡されます)

検証(PIN)コードを取得するまでは次へ進めないため、コード側ではinput()コマンドを使ってブラウザーからコピペされるのを待ちます。
oauth_verifier = input().strip()
auth_client = Client(consumer_key, client_secret=consumer_secret, verifier=oauth_verifier, resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret)
auth_url, auth_headers, auth_body = auth_client.sign(
'https://www.hatena.com/oauth/token',
http_method='POST',
req = urllib.request.Request(auth_url, method='POST', headers=auth_headers)
with urllib.request.urlopen(req) as res:
d = dict(urllib.parse.parse_qsl(res.read().decode()))
access_token = d['oauth_token']
access_token_secret = d['oauth_token_secret']
アクセストークンが取得できたならば、これらを環境変数や設定ファイルなどに記録しておけば、以下のAPIアクセスにはここまでの処理をスキップして直接API呼び出しを行うことができます。
(OAuth2と比べて有効期限やリフレッシュトークンによる更新はなさそう?ただし、上の操作を再度行うと既に払い出されたアクセストークンは無効になります)

はてなフォトライフに画像をアップロード

最後に、アクセストークンと投稿APIを使って実際に画像をアップロードします。ここではサンプル画像としてPlaceIMGからランダムな画像を取得してアップロードしています。
with urllib.request.urlopen('http://placeimg.com/240/120/any') as f:
    src_data = base64.b64encode(f.read()).decode()
    src_name = src_data[:16]
    src_dir = 'sample' 
access_client = Client(consumer_key, client_secret=consumer_secret, resource_owner_key=access_token, resource_owner_secret=access_token_secret)
access_url, access_headers, access_body = access_client.sign(
    'https://f.hatena.ne.jp/atom/post',
    http_method='POST',
    headers={'Accept': 'application/xml', 'Content-Type': 'application/xml'},
    body=f'''<?xml version="1.0" encoding="utf-8"?>
<entry xmlns="http://purl.org/atom/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<title>{src_name}</title>
<content mode="base64" type="image/jpeg">{src_data}</content>
<dc:subject>{src_dir}</dc:subject>
</entry>'''
req = urllib.request.Request(access_url, method='POST', headers=access_headers, data=access_body.encode())
with urllib.request.urlopen(req) as res:
print(res.read())
必要に応じてtitleタグには画像ファイル名、dc:subjectタグにはフォルダー名を設定します。うまくいけばsampleフォルダーに画像がアップロードされているはずです。

ハマりどころ

HTTPS接続が前提のOAuth2認証にくらべて、OAuth1認証は特に署名周りでなかなかうまくいかない事があります。

接続時にHTTPErrorが出る

ほとんどの場合OAuth1認証によるエラーになるため、urllib.request.Requestの部分をtry-except句でくくり、print(e.headers)を出力すればエラー原因を知ることができます。

画像の投稿がエラーになる

アクセストークンが有効であれば、はてなブックマークREST APIなど他のAPIも権限があれば使用できるので、そこで原因の切り分けができます。
使えないのであればアクセストークンが無効になっているため、最初からやり直して再取得します。

公式ドキュメントと同じなのにエラーになる

これが結構ハマったのですが、はてなフォトライフAPIの公式ドキュメントにあるサンプルコードだとAPIのエンドポイントがHTTP接続で記載されていますが、現時点ではHTTPS接続にしないとエラーになるようです。
また、投稿APIのドキュメントに記載の無いのですが、ヘッダーにAcceptとContent-Typeにapplication/xmlを指定しないと同じくエラーになるようでした。
(requests-oauthlibなど他のライブラリーを使えば自動で設定されるのかも?)
ちなみに、公式ドキュメントでは空のrealmを指定していますが、無くても動作したのとoauthlibでは空のrealmは仕様外のため設定できないようです。
(ただ、これを真似れば無理やり設定はできた)

アップロードした画像の一覧がAPIで取得できない

一応APIには一覧取得のエンドポイントがあるのですが、フォルダーが非公開だと出力されないようです。今回は投稿成功時のレスポンスをXMLファイルで記録しておく事で対応しました。

おわりに

本当は説明用のスクショとか入れたかったのですが、実行してしまうと今使っているアクセストークンが無効になってしまうため文字だけの説明になってしまいました。。はてなフォトライフは無料で毎月300MBまで画像をアップロードでき、非公開のフォルダーでもマイフォトでリンクを選択すればRSSフィードURLが取得できる(これが何気にすばらしい!)ので、ラズパイから毎時でアップロードされる画像をRSSフィードリーダーで時系列に閲覧する環境を構築する事ができました。