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 のクライアントでも書いてみようかな。

あま太郎

夏休み期間だからか、天神の飲食点はどこも混んでいて嫌になる。その中でも比較的空いていた、ソラリアステージ地下2階に古くからある『あま太郎』に入ってみた。 ここはうどんでは無く、ラーメンでも無く、『ちんめん』なるものを出している店。変わり種の麺はめんちゃんこ以来だな。

オーソドックスなホットちんめんを注文してみた。鶏ガラと醤油のスープはあっさりしていて、醤油ラーメンのようで結構違う。卵麺なので、その影響が大きいのかも。優しい味で、ご年配の方とか好きそう。長く続いてるのもわかる気がする。ホッと落ち着く味だった。

関連ランキング:ラーメン | 天神駅西鉄福岡駅(天神)天神南駅

『波よ聞いてくれ(1)〜(5)』を読んだ

Kindle版の1巻がたまたま無料になっていたので、試しに読んでみたら思いのほか面白くて、5巻まで買って読んでしまった。 何度目だこのパターン。

波よ聞いてくれ(1) (アフタヌーンコミックス)

主人公のミナレは滑舌が非常に良くて アドリブに強いスープカレー屋の店員。 つまり素人。 そのスープカレー屋の店員が、ラジオ局のディレクター麻藤にはめられたも同然の形で、ラジオの世界へ足を踏み入れることになった。

ミナレがその才能を発揮してラジオの世界で上り詰めて行く。 …という展開では今のところ無さそうで、 どちらかというとトラブルを引き寄せやすい体質の彼女が、身の周りで起こった事件をネタに番組を作り上げていくことが多い。 ミナレはかなり破茶滅茶で破滅型で、 側から見るぶんには楽しいけど、 巻き込まれたくはないねぇ。

途中、 オカルト路線突入か? いや殺人事件発生でサスペンスになるのか? なんて展開もあったが、 その結末があまりにも意外すぎて笑った。 いやまったく、予想だにしなかった。 5巻ではおかしな宗教団体まで出てきて、やっぱりオカルト路線か? それともシリアスになるのか?いや、やっぱりシリアスにはならないんだろう。

ラジオが題材である必要あるのかな、なんて疑問に思わなくもないが、 そんなイチ読者のささいな疑問なんかねじ伏せるパワーがある。 「生き急いでいるくらい」だと自称するミナレの勢いがダイレクトに伝わってきている感じだ。

担々麺 梟

会社帰りに、薬院にある『担々麺 梟』に行ってみた。 梟は天神店もあるけど、最初に行くならやっぱり本店の方でしょ。 近所には『元助』や『はなもこし』があり、かなりの激戦区だと思う。

担々麺の店に来たからには、食べねばなるまい『担々麺』。 麺はツルシコ。 豚骨スープがベースのスープは、 ピリ辛でいて腹にもたれず、 むしろスッキリとすら感じる飲み口。 あと、額に汗がほんのり滲む程度の辛さだった。 これが博多担々麺か。

結構空腹だったので、『担々麺』だけでなく『肉味噌ご飯』も注文した。 ピリ辛の肉味噌がご飯に合わないはずはない。 ペロリと完食。 あえて注文を付けるとしたら、 もうすこし量が欲しいくらいだ。

博多担々麺と称するだけあり、 いつも食べる胡麻の風味が効いた濃厚な担々麺とは一線を画す感じで、 新鮮で美味かった。 今回は自分のこだわりから本店に行ったけど、 天神店があるので、普段行くならそちらになりそう。

担々麺 梟

食べログ 担々麺 梟

とんかつのポーク凡平

現在絶賛再開発中の六本松は、 とら食堂や山本のハンバーグといった県外からの出店だけでなく、 五穀も移転してきたりと、 福岡のグルメシーンで今一番アツイ。 そんな六本松に新たにオープンしていた『とんかつのポーク凡平』に行ってきた。

ロースカツ定食とヒレカツ定食でギリギリまで悩んだけど、 ヒレカツ定食を選択。 赤身の肉が好きなもので。 脂身が苦手というのもある。 ご飯はおかわり無料。

こんなに柔らかいヒレカツは初めて。 厚い肉がスッと嚙み切れる。 歯がいらないかも。 まるで低温調理したかのようなジューシーさと柔らかさだった。

今のところ、マイとんかつランキング1位。 さらに上には、銘柄豚を使ったメニューが控えているというから恐ろしい。 そのぶん値が張るけど、食べてみたい。 でも、このヒレカツでもかなり満足。 なかなか平日夜や休日に外食すると機会がないので、 次に外食できるチャンスがあったら、また食べに来よう。

関連ランキング:とんかつ | 六本松駅別府駅桜坂駅

『王様達のヴァイキング(16)』を読んだ

15巻の感想で書いた通り、 蘇芳はテロリストに立ち向かった英雄として讃えられ、 その立場は揺るぎないものに。 是枝と笑い猫とヴァルキュリアのチームは解散かと思ったが、 是枝の熱意(?)によってなんとか継続するみたいだ。 実際のところ、是枝や笑い猫、ヴァルキュリアは、 ウィザード級ハッカーかそれ以上だと思うけど、 現実世界だと誰くらいなんだろうか。 ジェフ・ディーンくらいか?

16巻では、ヘッジホッグのメンバー達に久しぶりの活躍の場が。 ハッキング方面では笑い猫とヴァルキュリアがいるから出番無いもんなぁ。 エンジニアらしくチャットを駆使して密かに作戦を立て、 遊びで培ったドローン操作技術を生かして犯人を撃退。 無駄なことは何一つ無いのですよ、 たとえそれが遊びであっても。 なんて。 ヘッジホッグの皆は是枝の良い兄貴分て感じで好感が持てる。

今回の事件で、坂井には出口戦略、 というかゴールが見えたみたいだけど、 是枝とデウスをどう導いていくんだろうか。 その手腕に期待したいところだ。

王様達のヴァイキング(16) (ビッグコミックス)

王様達のヴァイキング(16) (ビッグコミックス)

名島亭

一風堂創業者の河原氏の兄弟子が開いた店で知られていて、 九州ラーメン総選挙で1位を獲ったこともある『名島亭』。

その名島亭本店がある東区名島まで足を伸ばしてみた。 よりにもよってこのクソ暑い中、 しかも貝塚駅から歩いて。 暑くて頭沸きそうだった。 むしろ既に頭沸いていたから、そんな愚行に及んだのかもしれない。

たくさん歩いて空腹の絶頂だったので、 欲張ってラーメンと名島の焼きめしを注文してみた。 ラーメンは正統派長浜ラーメンらしく、あっさりとした豚骨に極細麺。 ただし塩味はしっかりしている。 ノスタルジックな味わい。 長浜スタイルの完成形だと思う。

そして名島の焼きめし。 汗でミネラルを失った身体に嬉しい、 しっかりとした濃い味付け。 予想外だったのは、 人参と思って食べていたものが、 実は紅ショウガだったこと。 美味しくいただいている途中で、 ショウガの風味に気づいてしまった…。 紅ショウガは食べられるけど苦手なので、ラーメン定食にすればよかった。

帰りはさすがに歩いて帰る気にならなかったので、西鉄名島駅から電車に乗って帰った。公共交通機関で行けなくはないけど、福岡のだいぶ東の方なので、 車を持っていない身としてはそうそう行けない。 博多店もあるので、行くとしたらそっちだろうな。

関連ランキング:ラーメン | 名島駅