Amazon Product Advertising API を使って Amazon の商品情報を取得する

プログラムで Amazon の商品を検索したり、ASIN で商品情報を引っ張ってきたりしたくなったので、Amazon Product Advertising API を触ってみた。この API を呼び出すには、あらかじめ Amazon アソシエイトにログインして認証キーを取得しておく必要がある。

Amazon Product Advertising APIAPI 呼び出しの URL を作成するのがかなり面倒なので、 Product Advertising API Signed Requests Sample Code - C# REST/QUERY という古い C# のサンプルに含まれているヘルパーをちょっと修正して利用している。

/**********************************************************************************************
 * Copyright 2009 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file 
 * except in compliance with the License. A copy of the License is located at
 *
 *       http://aws.amazon.com/apache2.0/
 *
 * or in the "LICENSE.txt" file accompanying this file. This file is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under the License. 
 *
 * ********************************************************************************************
 *
 *  Amazon Product Advertising API
 *  Signed Requests Sample Code
 *
 *  API Version: 2009-03-31
 *
 */

using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Security.Cryptography;

namespace AmazonProductAdvtApi
{
    public class SignedRequestHelper
    {
        private string endPoint;
        private string akid;
        private string associateTag;
        private byte[] secret;
        private HMAC signer;

        private const string REQUEST_URI = "/onca/xml";
        private const string REQUEST_METHOD = "GET";

        /*
        * Use this constructor to create the object. The AWS credentials are available on
        * http://aws.amazon.com
        * 
        * The destination is the service end-point for your application:
        *  US: ecs.amazonaws.com
        *  JP: ecs.amazonaws.jp
        *  UK: ecs.amazonaws.co.uk
        *  DE: ecs.amazonaws.de
        *  FR: ecs.amazonaws.fr
        *  CA: ecs.amazonaws.ca
        */
        //public SignedRequestHelper(string awsAccessKeyId, string awsSecretKey, string destination)
        public SignedRequestHelper(string awsAccessKeyId, string awsSecretKey, string destination, string associateTag)
        {
            this.endPoint = destination.ToLower();
            this.akid = awsAccessKeyId;
            this.secret = Encoding.UTF8.GetBytes(awsSecretKey);
            this.associateTag = associateTag;
            this.signer = new HMACSHA256(this.secret);
        }

        /*
        * Sign a request in the form of a Dictionary of name-value pairs.
        * 
        * This method returns a complete URL to use. Modifying the returned URL
        * in any way invalidates the signature and Amazon will reject the requests.
        */
        public string Sign(IDictionary<string, string> request)
        {
            // Use a SortedDictionary to get the parameters in naturual byte order, as
            // required by AWS.
            ParamComparer pc = new ParamComparer();
            SortedDictionary<string, string> sortedMap = new SortedDictionary<string, string>(request, pc);

            // Add the AWSAccessKeyId and Timestamp to the requests.
            sortedMap["AWSAccessKeyId"] = this.akid;
            sortedMap["Timestamp"] = this.GetTimestamp();
            sortedMap["AssociateTag"] = this.associateTag;

            // Get the canonical query string
            string canonicalQS = this.ConstructCanonicalQueryString(sortedMap);

            // Derive the bytes needs to be signed.
            StringBuilder builder = new StringBuilder();
            builder.Append(REQUEST_METHOD)
                .Append("\n")
                .Append(this.endPoint)
                .Append("\n")
                .Append(REQUEST_URI)
                .Append("\n")
                .Append(canonicalQS);

            string stringToSign = builder.ToString();
            byte[] toSign = Encoding.UTF8.GetBytes(stringToSign);

            // Compute the signature and convert to Base64.
            byte[] sigBytes = signer.ComputeHash(toSign);
            string signature = Convert.ToBase64String(sigBytes);

            // now construct the complete URL and return to caller.
            StringBuilder qsBuilder = new StringBuilder();
            qsBuilder.Append("http://")
                .Append(this.endPoint)
                .Append(REQUEST_URI)
                .Append("?")
                .Append(canonicalQS)
                .Append("&Signature=")
                .Append(this.PercentEncodeRfc3986(signature));

            return qsBuilder.ToString();
        }

        /*
        * Sign a request in the form of a query string.
        * 
        * This method returns a complete URL to use. Modifying the returned URL
        * in any way invalidates the signature and Amazon will reject the requests.
        */
        public string Sign(string queryString)
        {
            IDictionary<string, string> request = this.CreateDictionary(queryString);
            return this.Sign(request);
        }

        /*
        * Current time in IS0 8601 format as required by Amazon
        */
        private string GetTimestamp()
        {
            DateTime currentTime = DateTime.UtcNow;
            string timestamp = currentTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
            return timestamp;
        }

        /*
        * Percent-encode (URL Encode) according to RFC 3986 as required by Amazon.
        * 
        * This is necessary because .NET's HttpUtility.UrlEncode does not encode
        * according to the above standard. Also, .NET returns lower-case encoding
        * by default and Amazon requires upper-case encoding.
        */
        private string PercentEncodeRfc3986(string str)
        {
            str = HttpUtility.UrlEncode(str, System.Text.Encoding.UTF8);
            str = str.Replace("'", "%27").Replace("(", "%28").Replace(")", "%29").Replace("*", "%2A").Replace("!", "%21").Replace("%7e", "~").Replace("+", "%20");

            StringBuilder sbuilder = new StringBuilder(str);
            for (int i = 0; i < sbuilder.Length; i++)
            {
                if (sbuilder[i] == '%')
                {
                    if (Char.IsLetter(sbuilder[i + 1]) || Char.IsLetter(sbuilder[i + 2]))
                    {
                        sbuilder[i + 1] = Char.ToUpper(sbuilder[i + 1]);
                        sbuilder[i + 2] = Char.ToUpper(sbuilder[i + 2]);
                    }
                }
            }
            return sbuilder.ToString();
        }

        /*
        * Convert a query string to corresponding dictionary of name-value pairs.
        */
        private IDictionary<string, string> CreateDictionary(string queryString)
        {
            Dictionary<string, string> map = new Dictionary<string, string>();

            string[] requestParams = queryString.Split('&');

            for (int i = 0; i < requestParams.Length; i++)
            {
                if (requestParams[i].Length < 1)
                {
                    continue;
                }

                char[] sep = { '=' };
                string[] param = requestParams[i].Split(sep, 2);
                for (int j = 0; j < param.Length; j++)
                {
                    param[j] = HttpUtility.UrlDecode(param[j], System.Text.Encoding.UTF8);
                }
                switch (param.Length)
                {
                    case 1:
                        {
                            if (requestParams[i].Length >= 1)
                            {
                                if (requestParams[i].ToCharArray()[0] == '=')
                                {
                                    map[""] = param[0];
                                }
                                else
                                {
                                    map[param[0]] = "";
                                }
                            }
                            break;
                        }
                    case 2:
                        {
                            if (!string.IsNullOrEmpty(param[0]))
                            {
                                map[param[0]] = param[1];
                            }
                        }
                        break;
                }
            }

            return map;
        }

        /*
        * Consttuct the canonical query string from the sorted parameter map.
        */
        private string ConstructCanonicalQueryString(SortedDictionary<string, string> sortedParamMap)
        {
            StringBuilder builder = new StringBuilder();

            if (sortedParamMap.Count == 0)
            {
                builder.Append("");
                return builder.ToString();
            }

            foreach (KeyValuePair<string, string> kvp in sortedParamMap)
            {
                builder.Append(this.PercentEncodeRfc3986(kvp.Key));
                builder.Append("=");
                builder.Append(this.PercentEncodeRfc3986(kvp.Value));
                builder.Append("&");
            }
            string canonicalString = builder.ToString();
            canonicalString = canonicalString.Substring(0, canonicalString.Length - 1);
            return canonicalString;
        }
    }

    /*
    * To help the SortedDictionary order the name-value pairs in the correct way.
    */
    class ParamComparer : IComparer<string>
    {
        public int Compare(string p1, string p2)
        {
            return string.CompareOrdinal(p1, p2);
        }
    }
}

この SignedRequestHelper と、あらかじめ取得しておいた認証キーを使って、ASIN から商品の詳細を取得するサンプルと、キーワードで商品を検索するサンプルを書いて見た。

using AmazonProductAdvtApi;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace ApaSample
{
    class Program
    {
        // AmazonProduct Advertising API のアクセスキー ID
        const string MY_AWS_ACCESS_KEY_ID = "<アクセスキー ID>";

        // AmazonProduct Advertising API のシークレットキー
        const string MY_AWS_SECRET_KEY = "<シークレットキー>";

        const string ASSOCIATE_TAG = "<YOUR_ASSOCIATE_TAG>";

        // 日本の Amazon が対象
        const string DESTINATION = "ecs.amazonaws.jp";

        // レスポンスの XML のネームスペース
        const string NAMESPACE = "http://webservices.amazon.com/AWSECommerceService/2011-08-01";

        static void Main()
        {
            var helper = new SignedRequestHelper(
                ACCESS_KEY_ID,
                SECRET_KEY,
                DESTINATION,
                ASSOCIATE_TAG);

            Console.WriteLine("# ItemLookup");
            ItemLookupSample(helper).GetAwaiter().GetResult();

            Console.WriteLine("# ItemSearch");
            ItemSearchSample(helper).GetAwaiter().GetResult();

            Console.WriteLine("Enter で終了します。");
            Console.ReadLine();
        }

        // ASIN が一致する商品の情報を取得する
        static async Task ItemLookupSample(SignedRequestHelper helper)
        {
            var request = new Dictionary<string, string>()
            {
                ["Service"] = "AWSECommerceService",
                ["Operation"] = "ItemLookup",
                ["ItemId"] = "B00XKM6TGY",  // 「波よ聞いてくれ」の ASIN
                ["ResponseGroup"] = "Small",
                //["ResponseGroup"] = "Large", // Large だと Amazon の商品ページ相当の情報を取得できるみたい
            };
            var requestUrl = helper.Sign(request);

            var result = await GetResponseAsync(requestUrl);
            foreach (var x in result)
            {
                Console.WriteLine(x);
            }
        }

        // キーワードで商品を検索する
        static async Task ItemSearchSample(SignedRequestHelper helper)
        {
            var request = new Dictionary<string, string>()
            {
                ["Service"] = "AWSECommerceService",
                ["Operation"] = "ItemSearch",
                ["SearchIndex"] = "All",
                ["Keywords"] = "かぐや様は告らせたい",
                ["ResponseGroup"] = "Small",
                //["ResponseGroup"] = "Large", // Large だと Amazon の商品ページ相当の情報を取得できるみたい
            };
            var requestUrl = helper.Sign(request);

            var result = await GetResponseAsync(requestUrl);
            foreach (var x in result)
            {
                Console.WriteLine(x);
            }
        }

        static async Task<IEnumerable<string>> GetResponseAsync(string url)
        {
            using (var client = new HttpClient())
            {
                var xml = await client.GetStringAsync(url);
                var xdoc = XDocument.Parse(xml);
                var result = from x in xdoc.Descendants()
                             where x.Name == XName.Get("Title", NAMESPACE)
                             where x.Parent.Name == XName.Get("ItemAttributes", NAMESPACE)
                             select x.Value;
                return result;
            }
        }
    }
}

実行結果は次の通り。ASIN のルックアップとキーワード検索、どちらも上手くいった。

f:id:griefworker:20180820101547p:plain

リクエストのパラメーターを Dictionary<string, string> に詰めるのはミスしやすそうなので、型を定義したいところ。 時間があれば、Amazon Product Advertising API のクライアントでも書いてみようかな。