プログラムで Amazon の商品を検索したり、ASIN で商品情報を引っ張ってきたりしたくなったので、Amazon Product Advertising API を触ってみた。この API を呼び出すには、あらかじめ Amazon アソシエイトにログインして認証キーを取得しておく必要がある。
Amazon Product Advertising API は API 呼び出しの 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 のルックアップとキーワード検索、どちらも上手くいった。
リクエストのパラメーターを Dictionary<string, string>
に詰めるのはミスしやすそうなので、型を定義したいところ。
時間があれば、Amazon Product Advertising API のクライアントでも書いてみようかな。