はてなフォトライフに画像をアップロードするアプリを作成しました。
はてなフォトライフAtomを使用しています。
WSSE認証、requests.post を使用した送信、安全な nonce の生成を検討しています。
機能的には次の特徴があります。
✅画像を exe にドラッグアンドドロップするだけでアップロードします。
✅はてなフォトライフのフォルダを指定できます。
✅ファイル名をタイトルに設定します。
✅はてな記法(fotolife記法)をログに出力します。
アプリの作り方をサンプルコードも交えて説明します。
📔 [はてなフォトライフへ画像をアップロードするアプリ【フリー】🔗
目次
- ◆機能・特長
- ◆考え方
- ◆はてなフォトライフAtomAPI
- ◆WSSEでの認証
- ◆データ作成
- ◆アップロード
- ◆ログ出力
- ◆XML 解析
- ◆全体処理
- ◆必要なパッケージ
- ◆全体のソース
- ❖ライブラリとして使用する場合の仕様
- ◆さいごに🦉
- ◆参考
◆機能・特長
- 画像を exe にドラッグアンドドロップするだけでアップロードします。
- はてなフォトライフのフォルダを指定できます。
- ファイル名をタイトルに設定します。
- はてな記法(fotolife記法)をログに出力します。
◆考え方
基本的な考え方は簡単です。
画像ファイルを選択して、送信用データを作成、認証データと合わせて送信します。
認証には、WSSE を使用しました。
画像の新規登録には、PostURI へ画像データを POST します。
◆はてなフォトライフAtomAPI
はてなフォトライフへ画像をアップロードするアプリケーションを作成するには、AtomAPI を利用すればよいと、はてなから出ています。
AtomAPI はウェブリソースを出版、編集するためのアプリケーション・プロトコル仕様です。はてなフォトライフのAtomAPIを利用することで、開発者ははてなフォトライフに写真を参照、投稿、編集、削除したりを行うオリジナルのアプリケーションを作成することができます。 Hatena Developer Center
はてなフォトライフAtomAPIがサポートしている操作
PostURI への POST
:新規写真の投稿EditURI への PUT
:投稿した写真のタイトルの変更EditURI への DELETE
:投稿した写真の削除EditURI への GET
:投稿した写真の参照FeedURI への GET
:最近投稿した写真の一覧の取得
今回は新規写真の投稿なので、PostURI への POST を使います。
◇WSSE認証
はてなフォトライフAtomAPIはRESTをサポートしています。
ユーザー認証は OAuth もしくは WSSE により行います。
ここでは、WSSE を使用します。
WSSE認証は、HTTPのX-WSSEヘッダを用いて認証用文字列を送信する認証手段です。
WSSE認証用文字列にはユーザー名とAPIキーが含まれます。
ヘッダサンプル
X-WSSE: UsernameToken Username = "hatena", PasswordDigest = "ZCNaK2jrXr4+zsCaYK/YLUxImZU=", Nonce = "Uh95NQlviNpJQR1MmML+zq6pFxE=", Created = "2005-01-18T03:20:15Z" Hatena Developer Center
各要素
Username
:はてなIDNonce
:HTTPリクエスト毎に生成したセキュリティ・トークン(SHA1アルゴリズムによって暗号化されたダイジェスト)Created
:Nonceが作成された日時をISO-8601表記で記述したものPasswordDigest
:Nonce, Created, APIキーを文字列連結しSHA1アルゴリズムでダイジェスト化して生成されたオクテット列を、Base64エンコードした文字列
※APIキー:はてなブログの管理画面⇒設定⇒詳細設定⇒APIキー で確認できます
◇PostURI - 新規写真の投稿
PostURIははてなフォトライフへ写真を新規投稿するためのエンドポイントです。POSTメソッドのみをサポートしています。PostURIに対し規定のリクエスト用XML文書をPOSTすることで写真の投稿が可能です。写真データBase64エンコードしてリクエスト用XML文書に記述します。また、Dublin Coreモジュールのdc:subject要素を追加することにより、ファルダ名を指定してアップロードすることができます。 Hatena Developer Center
title要素
テキスト
:ここでは写真のタイトル
content要素
:写真データdc:subject要素
:アップロード先のフォルダ名- 指定されたフォルダが はてなフォトライフ上に存在しない場合は、自動的に作成される
generator要素
:アップロードツール名- フォトライフの設定画面で、アップロードツール毎のフォルダ振り分けの設定が可能
操作が何かしらの理由によって失敗した場合は、正常レスポンスとは異なるステータスコードと、それに合わせたエラーメッセージがHTTPヘッダとして返ります。
◇応答
Pythonのrequestsパーケージでpostメソッドで要求を出した場合、応答が戻り値で返ってきます。
戻り値はResponseオブジェクトです。
Responseオブジェクトの主な属性
text
:応答内容(文字型) はてなフォトライフの場合ここが拡張されているheaders
:応答ヘッダー(辞書型)status_code
:応答のHTTPステータスencoding
:属性エンコーディングcontent
:応答内容(バイト型) はてなフォトライフの場合ここが拡張されている
content属性をencoding属性でデコードしたものはtext属性と同じ
Responseオブジェクトの主なメソッド
raise_for_status()
:HTTPエラー、またはURLエラーがあれば例外を発生
◇PostURI - 新規写真の投稿のレスポンス
- レスポンスは正常終了時としてHTTPステータス201を返します。
- レスポンスXML文書はHatena XML Namespaceによって拡張されています。
- サンプルのXXXXXXXXXXXXXXは画像のURLにおける末尾の数値部に置き換えてください。
- サンプルのYYYYYYは画像をアップロードした写真があるフォルダ名に置き換えてください。 Hatena Developer Center
実際の XML にある要素名
hatena:imageurl
:画像ファイルのURLhatena:imageurlsmall
:サムネイル画像(幅60px)のURLhatena:syntax
:画像のはてな記法hatena:imageurlmedium
:画像ファイル(幅120pxに縮小された)のURL
次に実際のコードを交えて実装内容を説明します。
◆WSSEでの認証
WSE 認証は「◇WSSE認証」で説明した内容をヘッダ用データとして作成します。
Username、Nonce、Created、PasswordDigest を作成します。
▶Nonce とは、
暗号通信で用いられる使い捨てのランダムな値です。
テスト的には乱数でもよいのですが、乱数の場合、ロジックによっては起動直後に同じ値が返ってくるため、それに対応したものが Python で用意されています。
当初、secrets.token_urlsafe() メソッドを使用したのですが、Base64 エンコードするのに4の倍数の長さが必要で、その作り方が難しくて止めました。
本アプリでは、secrets.token_bytes() メソッドを使用しています。
▶Created は、
Python の datetime.utcnow() メソッドで世界標準時を取得し、isoformat() メソッドで文字列にし、末尾に世界標準時を意味する Z
を付加します。
▶PasswordDigest は、
はてなの仕様にあるように
Nonce, Created, APIキーを文字列連結し、
SHA1アルゴリズムでダイジェスト化し、
生成されたオクテット列を、Base64エンコードした文字列
を作成しています。
【コード】
def wsse(self, username: str, api_key: str) ->str: """ WSSE認証 Args: str: はてなフォトライフのユーザーID str: はてなブログのapi key Returns: str: 送信用認証WSSEデータ """ # 安全なnonceの生成 # token_urlsafe()で生成してBase64でエンコードしたが長さは4の倍数でないとデコードできない # "="を付加して長さを調整する方法もあるようだがエンコードできるか心配なのでバイト型で作成してエンコードする # nonce64 = secrets.token_urlsafe(16) nonce = secrets.token_bytes() # 安全な乱数発生 nonce64 = b64encode(nonce).decode() # b64encodeはバイト型のため文字型に変換 created = datetime.utcnow().isoformat() + "Z" # UTC(協定世界時)でiso表記 # PasswordDigest:Nonce, Created, APIキーを文字列連結し、SHA1アルゴリズムでダイジェスト化 # 更にBase64エンコード password_digest = nonce + created.encode() + api_key.encode() # sha1の入力はバイト型のため password_digest = sha1(password_digest).digest() password_digest = b64encode(password_digest).decode() # WSSE認証文字列作成 s = f'UsernameToken Username="{username}", PasswordDigest="{password_digest}", Nonce="{nonce64}", Created="{created}"' return s
◆データ作成
データ作成は「◇PostURI - 新規写真の投稿」で説明した内容の XML データを作成します。
▶画像データは、
バイナリモードで読み込み Base64エンコードし文字列にするためデコードします。
content 要素で指定します。
content 要素では、type 属性に画像の Content-Type を指定します。
画像の拡張子から「image/拡張子」というデータを作成します。
JPEG の場合、拡張子が jpg である場合もあるので考慮します。
▶タイトルは、
はてなフォトライフにアップロードした画像は独自のファイル名に変換されます。
元のファイル名が分かるように元のファイル名を画像のタイトルにします。
title 要素で指定します。
▶フォルダは、
アップロード先のフォルダ名を dc:subject 要素で指定します。
【コード】
def create_data(self, file_name:str, title:str="", folder:str="Hatena Blog") ->str: """ 送信画像データの作成 Args: str: 画像のパス str: はてなフォトライフ上で画像につけるタイトル str: はてなフォトライフのフォルダ Returns: str: 送信用xmlデータ """ with open(file_name, "rb") as image_file: uploadData = image_file.read() uploadData = b64encode(uploadData).decode() extension_ = os.path.splitext(file_name)[1][1:].lower() # 拡張子のみ取得 if extension_ == "jpg": extension_ = "jpeg" xml_data = f"""<entry xmlns="http://purl.org/atom/ns#"> <title>{title}</title> <content mode="base64" type="image/{extension_}">{uploadData}</content> <dc:subject>{folder}</dc:subject> </entry>""" return xml_data
◆アップロード
headers, endpoint, data を指定して requests の postメソッドで電文を送ります。
▶headers は、
WSSE 認証用の X-WSSE ヘッダを作成し、指定します。
▶endpoint は、
はてなの仕様書の PostURI を指定します。
http://f.hatena.ne.jp/...
と記載されています。
しかし、はてなブログのhttps化が施されてからは、 http
s://f.hatena.ne.jp/...
と指定する必要があります。
▶data は、
要素の内容に日本語が含まれる場合、エンコードが必要です。
ここでは、title要素(タイトル)とdc:subject要素(フォルダ名)が該当します。
結果的には、post() メソッドの data=
で作成したデータをエンコードして与えばエンコードされます。
個別にエンコードして見たり、データの XML を辞書に変換して与えると内部的にエンコードされるとあったので、辞書にしてみたり(難しくてできていない)しました。
▶post() メソッドの結果は戻り値で取得できます。
戻り値の text 属性は、XML です。
「◇PostURI - 新規写真の投稿のレスポンス」で説明した要素が含まれます。
【コード】
def post_hatena(self, data:str) ->Tuple[str, str]: """ 画像アップロード Args: str: 送信データ Returns: str, str: 画像url、画像foto記法 """ headers = {'X-WSSE': self.wsse(os.getenv("py_hatena_username"), os.getenv("py_hatena_api_key"))} endpoint = 'https://f.hatena.ne.jp/atom/post/' # postのdataに日本語が含まれる場合、エンコードが必要 r = requests.post(endpoint, data=data.encode("utf-8"), headers=headers) print(f'--result-- status code={r.status_code}') if r.status_code != 201: sys.stderr.write(f'Error!\nstatus_code: {r.status_code}\nmessage: {r.text}') sys.exit(1) try: r.raise_for_status() # 200番代以外は例外を発生 # 例外がないのでurlを返す url1, url2 = self.get_image_url_et(r.text) print(f'url : {url1}') print(f'foto : {url2}') except: sys.stderr.write(f'Error!\nstatus_code: {r.status_code}\nmessage: {r.text}') if url1: return url1, url2 else: return "", ""
◆ログ出力
アップロードした画像の情報をログに出力します。
ログの情報でブログの下書きにコピペできることを目的にしました。
そのため、画像のリンク、画像の はてな記法を出力します。
ログのファイル名は「fotolife_yymmdd.log」(yymmdd は年月日)とし、追記で書き込みます。
◇ログに出力する項目
- 元のファイル名
- アップロード先フォルダ名
- アップロード日時
- 画像のリンク
- はてな記法での画像のリンク
◇EXEにドラッグアンドドロップした時の対応
EXE にファイルをドラッグアンドドロップされる対応をし、実際にドラッグアンドドロップされた場合、アプリのカレントディレクトリは、ドラッグ元のフォルダになります。
したがって、出力ファイルにフォルダを指定しないとドラッグアンドドロップしたかどうかで出力先が異なってしまいます。
更に、pyinstaller で作成した exe ファイルを起動した場合、もう一工夫必要です。
起動したフォルダを取得するには、sys.excutable プロパティを使います。
しかし、sys.excutable プロパティは本来、python インタプリタの場所を返すので、exe にする前の .py ファイルを実行する時には使えません。
したがって次のコードのような対応を必要とします。
# pyinstallerで作成したexeか判断してexeの場所を特定する if getattr(sys, 'frozen', False): exe_path = os.path.dirname(sys.executable) else: exe_path = sys.prefix
追加:2022-09-23
【コード】
def log_output(self, image_path:str, folder:str, image_url:str, foto:str): """ ログ出力 ファイルはカレントディレクトリに「fotolife_yymmdd.log」 追加型書き込み コピペでmarkdows記法で使用できるように編集して出力 Args: str: 画像ファイルパス str: フォルダ名 str: 画像url str: 画像foto記法 """ # pyinstallerで作成したexeか判断してexeの場所を特定する if getattr(sys, 'frozen', False): exe_path = os.path.dirname(sys.executable) else: exe_path = sys.prefix foto = foto.replace(":image", ":plain") logfile_name = f"fotolife_{datetime.now().strftime('%y%m%d')}.log" logfile_name = os.path.join(exe_path, logfile_name) upload_time = f"Time:{datetime.now().strftime('%Y-%m-%d %H:%M')}" msg = f"\n【{os.path.basename(image_path)}】Folder:{folder} {upload_time}\n url : ![]({image_url}) \n foto : [{foto}] \n" try: with open(logfile_name, mode="a") as file_: file_.write(msg) except Exception as e: print(f"書き込みエラー:{e}")
◆XML 解析
画像のリンクや画像の はてな記法は応答に含まれる XML データに記述されています。
標準ライブラリの ElementTree を使用して XML を解析し、データを取得します。
まずXML のルートを取得して、次にルートの配下のタグを捜します。
タグの検索には iter() メソッドを使います。
iter() メソッドはイテレータが返るので next() メソッドでオブジェクトを取得します。
オブジェクトの text 属性が要素の内容です。
また、返される XML はタグ名に名前空間が指定されています。
iter() メソッドでの名前空間の指定は、タグ名を「{名前空間名}タグ名」とします。
XML から取得する要素
hatena:imageurl
:画像のリンクhatena:syntax
:画像のはてな記法
レスポンスで返される XML
<entry xmlns="http://purl.org/atom/ns#" xmlns:hatena="http://www.hatena.ne.jp/info/xmlns#"> <title>Sample</title> <link rel="alternate" type="text/html" href="http://f.hatena.ne.jp/naoya/XXXXXXXXXXXXXX"/> <link rel="service.edit" type="application/x.atom+xml" href="http://f.hatena.ne.jp/atom/edit/XXXXXXXXXXXXXX" title="Sample"/> <issued>2005-01-14T17:01:29+09:00</issued> <author> <name>naoya</name> </author> <generator url="http://f.hatena.ne.jp/" version="1.0">Hatena::Fotolife</generator> <dc:subject xmlns:dc="http://purl.org/dc/elements/1.1/">YYYYYY</dc:subjetct> <id>tag:hatena.ne.jp,2005:fotolife-naoya-XXXXXXXXXXXXXX</id> <hatena:imageurl>http://f.hatena.ne.jp/images/fotolife/n/naoya/XXXXXXXX/XXXXXXXXXXXXXX.jpg</hatena:imageurl> <hatena:imageurlsmall>http://f.hatena.ne.jp/images/fotolife/n/naoya/XXXXXXXX/XXXXXXXXXXXXXX_m.gif</hatena:imageurlsmall> <hatena:syntax>f:id:naoya:XXXXXXXXXXXXXX:image</hatena:syntax> </entry>[Hatena Developer Center](http://developer.hatena.ne.jp/ja/documents/fotolife/apis/atom)
【コード】
import xml.etree.ElementTree as ET def get_image_url_et(self, xml:str) -> Tuple[str, str]: """ xmlからETのiterで情報を取得 Args: str: xml Returns: str, str: 画像url、画像foto記法 """ root = ET.fromstring(xml) url1 = next(root.iter("{http://www.hatena.ne.jp/info/xmlns#}imageurl")).text url2 = next(root.iter("{http://www.hatena.ne.jp/info/xmlns#}syntax")).text return url1, url2
◆全体処理
【処理】
- コマンドライン引数からファイルの指定があった場合、それを取得する
- なかった場合、ファイルダイアログを表示して指定されたファイルを取得する
※はてなフォトライフの対象画像はPNG, JPEG, GIF - アップロード先フォルダ名を入力
入力されたらキーワード引数になるように辞書に登録 - 指定されたファイルごとに以下を処理
- 対象外のファイルは処理を飛ばす
- ファイル名をタイトルとする
- 送信データの作成
- アップロードと結果取得
- ログ出力
◇ファイルダイアログだけを表示する方法
本アプリは、ファイルダイアログを除き Tkinter の GUI を使用しないで作成しています。
デフォルトでファイルダイアログを表示させるとトップレベルウィンドウが出てしまいます。
それを出さないようにするには、自動で作成されるトップレベルウィンドウを手動で作成(root = tk.Tk()
)し、撤去状態(root.withdraw()
)にします。
【コード】
def upload_image_to_hatena(): """ 画像をはてなフォトライフへアップロードする はてなフォトライフのアップロード先フォルダを入力して指定する 画像はコマンドライン引数、基本はドラッグアンドドロップ """ hatena_atom = HatenaFotolifeAtom() exp = (".png", ".jpg", ".jpeg", ".gif") # 対象の拡張子 kwargs = {} # コマンドライン引数からドラッグ&ドロップされたファイル情報を取得 if len(sys.argv) > 1: file_paths = tuple(sys.argv[1:]) else: # 画像を指定 root = tk.Tk() # 自動で作成されるToplevelオブジェクトを手動で作成し root.withdraw() # 撤去状態にする file_paths = filedialog.askopenfilenames( filetypes=[("画像", ".png .jpg .jpeg .gif"), ("PNG", ".png"), ("JPEG", ".jpg .jpeg"), ("GIF", ".gif"), ("すべて", "*")]) # アップロード先フォルダを入力 folder = input("はてなフォトライフのフォルダ名を指定してください(無指定の場合は「Hatena Blog」)\n>") if not folder: folder = "Hatena Blog" kwargs["folder"] = folder # ファイルごとにアップロード for file_ in file_paths: if not os.path.splitext(file_)[1].lower() in exp: print(f"File : {os.path.basename(file_)}は対象外のファイルです。") continue title_ = os.path.splitext(os.path.basename(file_))[0] # ローカルファイル名をタイトルに data = hatena_atom.create_data(file_, title_, **kwargs) # 送信データ作成 print(f"【{os.path.basename(file_)}】") image_url, foto = hatena_atom.post_hatena(data) # 送信と結果取得 hatena_atom.log_output(file_, folder, image_url, foto) # ログ出力 input("\n確認したらEnterキーを押してください")
◆必要なパッケージ
- requests
【インストール】pip install requests
【インポート】 import requests
◆全体のソース
全体のソースはこちらから取得できます。
- ソース:fotolifeUpload.py
- 取得先:GitHub juu7g/Python-fotolife-Upload
❖ライブラリとして使用する場合の仕様
◇クラス
◇依存関係 - 環境変数の設定
ライブラリで、はてなフォトライフへ画像をアップロードするには、環境変数に次のものを設定する必要があります。
▶詳しい説明はこちらの記事にあります。
📔◆環境変数の設定 - はてなフォトライフへ画像をアップロードするアプリ【フリー】 🔗
更新:2022-10-22
◆さいごに🦉
今回ははてなフォトライフの Atom を使って はてなフォトライフへアクセスしました。
はてなブログ用の はてなブログ AtomPub も良く似ているのではないかと思っています。
はてなブログ AtomPub を使えば、記事の投稿もできるようになります。
投稿ならまだいいのですが、更新とかとなるとリスクが怖いです。
いずれ、リスクの低い用途をみいだして挑戦してみようかと思います。
何かご要望があれば遠慮なく・・・
今回は、エンドポイントに始まり、日本語のエンコードや nonce の作成でいろいろ勉強になりました。
- 画像サイズを変更し文字透かしを入れるアプリの作り方【Python】
- 画像ビューアの作り方(Treeviewに画像と疑似チェックボックス)【Python】
- シンプル画像ビューアの作り方(マウスホイール対応)【Python】
□ご注意
ご利用に際しては、『免責事項』をご確認ください。
本記事は次のバージョンの下で動作した内容を元に記述しています。
- Python 3.8.5
- Requests 2.25.1
◇免責事項
ご利用に際しては、『免責事項』をご確認ください。
お気づきの点がございましたら『お問い合わせ』からお問い合わせください。
◆参考
- はてなフォトライフAtomAPI:はてなフォトライフAtomAPI - Hatena Developer Center
- はてなWSSE認証:はてなサービスにおけるWSSE認証 - Hatena Developer Center
- はてな告知:はてな提供の API の URL 変更および廃止のお知らせ - はてなの告知
- はてなxml:はてなXML名前空間 - Hatena Developer Center
- POST解説:AtomAPIを使用してサクっとはてなフォトライフへ画像をアップロードする - Qiita
- WSSE解説:pythonでwsse認証を用いて、はてなブログにエントリーを投稿する - Qiita
- フォトライフの弱点:はてなブロガーがはてなフォトライフよりもflickrを使うべき理由 - TotalTech
- 解説:PyinstallerでビルドしたEXEが存在するディレクトリを取得する方法 - Qiita
◇特別な感謝
特にこちらの記事の解説は参考になりました。
コードはほとんど利用させていただいてる感じです。
ありがとうございました。
- はてなアップ解説:Python3→はてなフォトライフへ画像のアップロード - lisz-works
- はてな応答解説: Python3→はてなフォトライフへアップした画像URLを取得する(コピペでOK) - lisz-works