新着記事

カテゴリーの投稿を見る Python

Amazon マーケットプレイスWebサービス (MWS) APIから注文情報を取得する方法

当社では、Amazon のマーケットプレイスに出店していたり、FBA (フルフィルメントByアマゾン: Amazon社の倉庫に商品を納品し、販売を代行してもらう販売方法) を行っています。

マーケットプレイスにはAPIが用意されており、リクエストすることで受注情報など多くの情報を取得できるのですが、署名の計算に少し躓いたので書いておきます。

Amazon MWS 公式ガイド

アクセスに必要な情報を集める

認証情報 (クレデンシャル)

アクセスに必要な認証情報は、

  1. 出品者ID (マーチャントID, セラーIDと呼ばれることもある)
  2. AWSアクセスキーID
  3. 秘密キー(シークレットキー)

の3つです。もし、アクセスするアカウントがセラーセントラルのアカウントではなく、派生して作られた子アカウントである場合、別途「MWS認証トークン」が必要になります。

これらの情報は、すべてセラーセントラルの「設定」→「ユーザー権限」のページで取得できます。AWSアクセスキーID,秘密キー は、ページ下部の「Amazon MWS 開発者権限」の、「認証情報を表示」をクリックすると表示されます。

マーケットプレイスID

別途、マーケットプレイスID という文字列が必要になる場合があります。

マーケットプレイスIDの一覧はページから確認できます。

Amazon マーケットプレイス Web サービスエンドポイント

例えば、日本なら A1VC38T7YXB528 です。

APIアクセスのテストをする

Amazon社がAPIのテストツール「Scratchpad」を公開しているので、それを使います。後述する HMAC の計算が正しいか確認する意味でも、このツールは必ず使ってみたほうが良いです。

  1. Amazon MWS Scratchpad を開く
  2. 左上「API Selection」を適当に選択。今回は、API セクション: 注文、Operation: ListOrders
  3. Authentication 欄に認証情報を入力、SellerId: には 出品者ID, MWSAuthToken は元アカウントなら空、払い出された子アカウントならトークン文字列を入れる。AWSAccessKeyId、Secret Kye は先ほど取得した文字列を取得。
  4. API必須パラメータの「MarketplaceId.Id.1」には、A1VC38T7YXB528 を入力
  5. API任意パラメータの「LastUpdatedAfter」のみ、2017-05-05 のように入力
  6. 「送信」をクリックすると、結果が表示されます。

HMAC の値を確認しておく

結果が正常に表示された場合、「リクエスト」タブをクリックして開いてみると「署名対象の文字列」というセクションと、その下に計算した HMAC署名 が表示されています。APIを開発する時は、この情報を元に開発するとやりやすいです。「証明対象の文字列」に対して SHA 256 HMAC で計算を行い、その下に書かれている文字列が結果で得られるよう開発をしていきます。

APIライブラリを開発する

Pythonで作ります。

署名方法は、Amazonで「署名バージョン2」と言われる方法です。

公式ドキュメントの署名のロジック説明 (Java のサンプルコードあり)

HMACの計算ロジックの作成

Python には hmac ライブラリがあるので、それを使えばすぐにできます。

Python3での例

import hmac
import hashlib
import base64

secret_key = b"取得した秘密キー"

canonical = b"""
POST
… 「証明対象の文字列」をここにコピペ …
"""

h = hmac.new(secret_key, canonical.strip(), hashlib.sha256)

print(h.hexdigest())
print(base64.b64encode(h.digest()))

これを実行すると、Scratchpad に表示されている「SHA 256 HMAC」「Base64 HMAC」と同じ値が取得できるはずです。

署名対象の文字列の作成

署名対象の文字列は、HTTPメソッド(POST)、ドメイン名(mws.amazonservices.jp)、パス(/Orders/2013-09-01)、それとクエリ文字列を、改行(\n)で連結して作ります。

クエリ文字列の作成

クエリ文字列は、検索パラメータ名をソートさせ、&= で連結して作ります。値は URL エンコードします。

ディクショナリで値を用意してたとすると、

import datetime
import urllib.parse

AMAZON_CREDENTIAL = {
    'SELLER_ID': 'セラーID',
    'ACCESS_KEY_ID': 'AWSアクセスキーID',
    'ACCESS_SECRET': 'アクセスシークレット',
}

data = {
    'AWSAccessKeyId': AMAZON_CREDENTIAL['ACCESS_KEY_ID'],
    'Action': 'ListOrders',
    'MarketplaceId.Id.1': 'A1VC38T7YXB528',
    'SellerId': AMAZON_CREDENTIAL['SELLER_ID'],
    'SignatureMethod': 'HmacSHA256',
    'SignatureVersion': '2',
    'Timestamp': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
    'Version': '2013-09-01',
}

query_string = '&'.join('{}={}'.format(
    n, urllib.parse.quote(v, safe='')) for n, v in sorted(data.items()))

print(query_string)

このようなロジックで作成できます。

sorted メソッドでキーで並び替えを行い、値は urllib.parse.quote で URLエンコードします。safe='' を入れないと / がエンコードされないので、入れます。

後は、改行で連結すれば署名対象文字列になります。

canonical = "{}\n{}\n{}\n{}".format(
    'POST', 'mws.amazonservices.jp', '/Orders/2013-09-01', query_string
)

print(canonical)

署名をつけてリクエストする方法

リクエストメソッドは POST です。ですが、POSTのデータは空で、パラメータはURLのクエリストリングに入れます。

署名は、クエリストリングの末尾に &Signature=署名 という形で付与します。

requests でリクエストしてみる

実際にリクエストするコードを書いてみます。

import base64
import datetime
import hashlib
import hmac
import urllib.parse

import requests
import six

AMAZON_CREDENTIAL = {
    'SELLER_ID': 'セラーID',
    'ACCESS_KEY_ID': 'AWSアクセスキーID',
    'ACCESS_SECRET': 'アクセスシークレット',
}

DOMAIN = 'mws.amazonservices.jp'
ENDPOINT = '/Orders/2013-09-01'


def datetime_encode(dt):
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')


timestamp = datetime_encode(datetime.datetime.utcnow())

last_update_after = datetime_encode(
datetime.datetime.utcnow() - datetime.timedelta(days=1))

data = {
'AWSAccessKeyId': AMAZON_CREDENTIAL['ACCESS_KEY_ID'],
'Action': 'ListOrders',
'MarketplaceId.Id.1': 'A1VC38T7YXB528',
'SellerId': AMAZON_CREDENTIAL['SELLER_ID'],
'SignatureMethod': 'HmacSHA256',
'SignatureVersion': '2',
'Timestamp': timestamp,
'Version': '2013-09-01',
'LastUpdatedAfter': last_update_after,
}

query_string = '&'.join('{}={}'.format(
n, urllib.parse.quote(v, safe='')) for n, v in sorted(data.items()))

canonical = "{}\n{}\n{}\n{}".format(
'POST', DOMAIN, ENDPOINT, query_string
)

h = hmac.new(
six.b(AMAZON_CREDENTIAL['ACCESS_SECRET']),
six.b(canonical), hashlib.sha256)

signature = urllib.parse.quote(base64.b64encode(h.digest()), safe='')

url = 'https://{}{}?{}&Signature={}'.format(
DOMAIN, ENDPOINT, query_string, signature)

response = requests.post(url)

print(response.content.decode())

下品にベターっと書いてますが、これで動きます。

実際にはこれをライブラリ化して肉付けしていくと良いでしょう。

EPUBファイルから画像を抽出する

電子書籍フォーマットとして広く使われている EPUB ファイルから、連番で画像を抽出する方法です。

ツール作りました! pip でインストールできます。https://github.com/ytyng/epub-extract-jpeg

EPUB ファイルの概要

EPUB ファイルとは、平たく言えば ZIP圧縮された XHTML です。 コミックで一般的に使われる形式では、1ページが1つの XHTML ファイルになっており、その中に 1 つの img タグが あり、画像ファイルにリンクされています。

そのため、手順としては

  • EPUB ファイルを解凍
  • 構成情報の XML ファイルを解析し、ページ画像の URL (パス) を取得
  • ページ画像を連番で改名コピー(移動)

となります。

1. EPUBファイルを解凍

unzip で一発です。

$ mkdir /tmp/epub-extract
$ unzip sample.epub -d /tmp/epub-extract

2. 構成情報の XML ファイルを解析し、ページ画像の URL (パス) を取得

まず、展開後のディレクトリにある META-INF/container.xml を開きます。 ここに、rootfile というタグがあるので、その full-path 属性を見ます。 full-path の XML ファイルが、各ページの目次のようなものになります。

full-path が示す XML ファイルで、manifest タグの中に item タグが複数あります。 これらは、EPUB 中の XHTML から使われているファイルです。

3. ページ画像を連番で改名コピー(移動)

この、item タグの中はおそらくページ順になっているので、このファイルをスクリプトで連番で改名コピーしながら収集すれば、ページ画像を抽出できます。

本来であれば、直接画像のパスを読むのではなく、ページの XHTML ファイルを開き、そこからリンクされている画像を収集していくのが正しいのですが、ページの XHTML の順と画像の item タグの順が一致しないケースは稀だと思いますので(EPUB 作成者が意図的に XHTML ファイルと画像ファイルの順番を一致させなかった場合などは、ページ数が正しく取得できません)、item タグの順で処理して基本的には問題無いでしょう。

これで、EPUB ファイルから画像ファイルを抽出する方法は終わりです。 最後に、Python スクリプトにした例を掲載しておきます。

from __future__ import print_function, unicode_literals

import os
import time
import sys
import subprocess
import shutil
from xml.etree import ElementTree

TEMP_DIR = '/tmp/epub-extract-{}'.format(int(time.time()))


def procedure(file_path):
    if not os.path.exists(file_path):
        print("{} is not exist.".format(file_path), file=sys.stderr)
        return

    output_dir, ext = os.path.splitext(file_path)

    if ext != '.epub':
        print("{} is not epub.".format(file_path), file=sys.stderr)
        return

    if os.path.exists(output_dir):
        print("{} is already exists.".format(output_dir), file=sys.stderr)
        return

    os.mkdir(TEMP_DIR)

    subprocess.Popen(
        ('unzip', file_path, "-d", TEMP_DIR),
        stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()

    os.mkdir(output_dir)

    container_xml_path = os.path.join(TEMP_DIR, 'META-INF', 'container.xml')
    etree = ElementTree.parse(container_xml_path)
    rootfile_node = etree.find(
        ".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
    content_opf_path = rootfile_node.attrib['full-path']

    content_xml_path = os.path.join(TEMP_DIR, content_opf_path)
    etree = ElementTree.parse(content_xml_path)
    manifest = etree.find('.//{http://www.idpf.org/2007/opf}manifest')
    items = manifest.findall('.//{http://www.idpf.org/2007/opf}item')

    image_paths = []
    for item in items:
        if item.attrib['media-type'] == 'image/jpeg':
            image_paths.append(item.attrib['href'])

    root_dir = os.path.dirname(content_xml_path)

    for i, image_path in enumerate(image_paths, start=1):
        destination_image_name = '{:03d}.jpg'.format(i)
        source_image_path = os.path.join(root_dir, image_path)
        destination_image_path = os.path.join(
            output_dir, destination_image_name)
        shutil.move(source_image_path, destination_image_path)
        print('{} -> {}'.format(image_path, destination_image_name))

    shutil.rmtree(TEMP_DIR)


def main():
    for arg in sys.argv[1:]:
        procedure(arg)


if __name__ == '__main__':
    main()
追記: Github に上げて、pip でインストールできるようにしました。
検索

最近のツイート

  • ytyng

    ytyng @ytyng

    やばい、機能がリリースされない! タイムゾーンか? サマータイム設定になってるのか? とか焦ってたら、リリース日になってなかっただけだった。今日月末じゃん。月初じゃないじゃん。1人で焦ってたけど結果1日得した気分
    2 ヶ月, 3 週間 前

  • ytyng

    ytyng @ytyng

    class SerialCode って書こうとして、class Serialcode (非キャメルの1単語) の方がいいのではと思ってしまう。Datasturcture, Javascript みたいに非キャメル1単語で書きそうになるのは症状名があるのだろうか
    4 ヶ月 前

  • おてふ

    おてふ @otef

    ytyng

    ほんとうに頭がおかしいコマしかないのに読後感は非常に爽やかという奇跡のマンガ、男日本海が今なら無料なのでみなさん読んでおいたほうがいいですよほんとに https://t.co/AsIZsNuwiP https://t.co/kKl77lJIlH
    4 ヶ月, 2 週間 前

  • ytyng

    ytyng @ytyng

    昼間なのに影が多方向に出るのってちょっと面白い https://t.co/x1scFI2Uk2
    4 ヶ月, 2 週間 前

  • ytyng

    ytyng @ytyng

    https://t.co/FUChcVIVpm になって、パッケージの登録ができないんですけど、Python有識者の方にどうしたらいいか教えてほしい。 https://t.co/AEkCfbml6Z このガイドの通り twine 使っても HTTPError 410 っていう
    4 ヶ月, 3 週間 前