プログラムでおかえしできるかな

定年を過ぎて何かの役に立てないかなと始めた元SEのブログです

はてなフォトライフへ画像をアップロードするアプリの作り方(AtomAPIの使い方)【Python】

このエントリーをはてなブックマークに追加

はてなフォトライフに画像をアップロードするアプリを作成しました。
はてなフォトライフAtomを使用しています。

WSSE認証、requests.post を使用した送信、安全な nonce の生成を検討しています。


機能的には次の特徴があります。
✅画像を exe にドラッグアンドドロップするだけでアップロードします。
はてなフォトライフのフォルダを指定できます。
✅ファイル名をタイトルに設定します。
はてな記法fotolife記法)をログに出力します。

アプリの作り方をサンプルコードも交えて説明します。

アプリはお使いいただけます アプリ(バイナリ)を使ってみたい方は、別記事から取得できます。
 記事『はてなフォトライフへ画像をアップロードするアプリ【フリー】
目次

◆機能・特長

◆考え方

基本的な考え方は簡単です。 画像ファイルを選択して、送信用データを作成、認証データと合わせて送信します。
認証には、WSSE を使用しました。
画像の新規登録には、PostURI へ画像データを POST します。

はてなフォトライフAtomAPI

はてなフォトライフへ画像をアップロードするアプリケーションを作成するには、AtomAPI を利用すればよいと、はてなから出ています。

AtomAPI はウェブリソースを出版、編集するためのアプリケーション・プロトコル仕様です。はてなフォトライフAtomAPIを利用することで、開発者ははてなフォトライフに写真を参照、投稿、編集、削除したりを行うオリジナルのアプリケーションを作成することができます。 Hatena Developer Center

はてなフォトライフAtom仕様についてまとめてみました。

はてなフォトライフ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

各要素

◇PostURI - 新規写真の投稿

PostURIははてなフォトライフへ写真を新規投稿するためのエンドポイントです。POSTメソッドのみをサポートしています。PostURIに対し規定のリクエスト用XML文書をPOSTすることで写真の投稿が可能です。写真データBase64エンコードしてリクエスト用XML文書に記述します。また、Dublin Coreモジュールのdc:subject要素を追加することにより、ファルダ名を指定してアップロードすることができます。 Hatena Developer Center

リクエスト用XML文書に設定可能な要素、属性

  • 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:画像ファイルのURL
  • hatena:imageurlsmall:サムネイル画像(幅60px)のURL
  • hatena: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化が施されてからは、 https://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()
            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 は年月日)とし、追記で書き込みます。

ログに出力する項目

  • 元のファイル名
  • アップロード先フォルダ名
  • アップロード日時
  • 画像のリンク
  • はてな記法での画像のリンク

【コード】

    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記法
        """
        foto = foto.replace(":image", ":plain")
        logfile_name = f"fotolife_{datetime.now().strftime('%y%m%d')}.log"
        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

【コード】

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

◆全体処理

【処理】

  1. コマンドライン引数からファイルの指定があった場合、それを取得する
  2. なかった場合、ファイルダイアログを表示して指定されたファイルを取得する
    はてなフォトライフの対象画像はPNG, JPEG, GIF
  3. アップロード先フォルダ名を入力
    入力されたらキーワード引数になるように辞書に登録
  4. 指定されたファイルごとに以下を処理
    1. 対象外のファイルは処理を飛ばす
    2. ファイル名をタイトルとする
    3. 送信データの作成
    4. アップロードと結果取得
    5. ログ出力

◇ファイルダイアログだけを表示する方法

本アプリは、ファイルダイアログを除き TkinterGUI を使用しないで作成しています。
デフォルトでファイルダイアログを表示させるとトップレベルウィンドウが出てしまいます。
それを出さないようにするには、自動で作成されるトップレベルウィンドウを手動で作成(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

◆全体のソース

全体のソースはこちらから取得できます。
取得先:GitHub juu7g/Python-fotolife-Upload

◆さいごに🦉

今回ははてなフォトライフAtom を使って はてなフォトライフへアクセスしました。
はてなブログ用の はてなブログ AtomPub も良く似ているのではないかと思っています。
はてなブログ AtomPub を使えば、記事の投稿もできるようになります。
投稿ならまだいいのですが、更新とかとなるとリスクが怖いです。
いずれ、リスクの低い用途をみいだして挑戦してみようかと思います。
何かご要望があれば遠慮なく・・・

今回は、エンドポイントに始まり、日本語のエンコードや nonce の作成でいろいろ勉強になりました。

あわせて読みたい - 画像関連

□ご注意

ご利用に際しては、『免責事項』をご確認ください。

本記事は次のバージョンの下で動作した内容を元に記述しています。

  • Python 3.8.5
  • Requests 2.25.1

◆参考

◇特別な感謝

特にこちらの記事の解説は参考になりました。
コードはほとんど利用させていただいてる感じです。
ありがとうございました。

投稿: