2011年1月19日水曜日

WebMatrixでAmazon検索

今回はAmazon.co.jpの商品を検索するためのHelperを作ってみたよ。

今まで同様、WebMatrixでスターターサイトがある前提で。今まで作ったヘルパーもちょいちょい拡張しつつ載せていきます。

~/App_Code/Utility.cshtml

@functions {
    public static new dynamic Cache(string key, int expireSeconds, Func<dynamic> thunk){
        var context = new HttpContextWrapper(HttpContext.Current);
        return Cache(context, key, expireSeconds, thunk);
    }
    
    public static new dynamic Cache(HttpContextBase context, string key, int expireSeconds, Func<dynamic> thunk){
        dynamic value = context.Cache[key];
        if(value == null) {
            value = thunk();
            context.Cache.Insert(
                key,
                value,
                null,
                DateTime.Now.AddSeconds(expireSeconds),
                System.Web.Caching.Cache.NoSlidingExpiration
            );
        }
        
        return value;
    }

    public static string WebRequest(Uri uri)
    {
        string response;
        using (var client = new WebClient())
        {
            client.Encoding = System.Text.Encoding.UTF8;
            response = client.DownloadString(uri);
        }
        return response;
    }
}

UtilityヘルパーにはキャッシュだけじゃなくてHTTP GETを実行するメソッドを追加してます。なので郵便番号検索なんかもこっちを呼ぶようにリファクタリングしたほうがいいね。

~/App_Code/Aws.cshtml

@using System.Text
@using System.Security.Cryptography
@using System.Web.Routing;
@using System.Xml.Linq;
@using System.Dynamic;

@functions {

    public static class AwsSetting
    {
        public static XNamespace Namespace = "http://webservices.amazon.com/AWSECommerceService/2010-11-01";
        public static readonly string Host = "ecs.amazonaws.jp";
        public static readonly string Path = "/onca/xml";
        public static readonly string Service = "AWSECommerceService";
        public static readonly string Version = "2010-11-01";

        public static readonly string AWSAccessKeyId = "アクセスキーID";
        public static readonly string SecretAccessKey = "シークレットアクセスキー";
    }

    /*
     * 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 static string PercentEncodeRfc3986(string str)
    {
        str = HttpUtility.UrlEncode(str, 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();
    }

    public static string GenerateSettingQueryString(RouteValueDictionary parameters)
    {
        var pairs = new SortedDictionary<string, string> { 
            {"AWSAccessKeyId",AwsSetting.AWSAccessKeyId},
            {"Service",AwsSetting.Service},
            {"Version",AwsSetting.Version}
        };
        parameters["Timestamp"] = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.000Z");
        foreach (var kv in parameters)
            pairs.Add(kv.Key, (string)kv.Value);

        var qs = from kv in pairs
                 select string.Format("{0}={1}", kv.Key, PercentEncodeRfc3986(kv.Value));
        return string.Join("&", qs);
    }

    public static Uri GenerateUri(object parameters)
    {
        return GenerateUri(new RouteValueDictionary(parameters));
    }
    
    public static Uri GenerateUri(RouteValueDictionary parameters)
    {
        var values = GenerateSettingQueryString(parameters);
        var unsigned = string.Join("\n", new[] { "GET", AwsSetting.Host, AwsSetting.Path, values });
        
        byte[] key = Encoding.UTF8.GetBytes(AwsSetting.SecretAccessKey);
        byte[] data = Encoding.UTF8.GetBytes(unsigned);
        var hmac = new HMACSHA256(key);
        var hash = hmac.ComputeHash(data);
        var signature = PercentEncodeRfc3986(Convert.ToBase64String(hash));

        return new Uri(string.Format("http://{0}{1}?{2}&Signature={3}", AwsSetting.Host, AwsSetting.Path, values, signature));
    }

    private static dynamic DynamicExpandoObject(Action<dynamic> init)
    {
        dynamic model = new ExpandoObject();
        init(model);
        return model;
    }
    
    private static string ElementValue(XElement parent, string name)
    {
        var ns = AwsSetting.Namespace;
        if (parent == null)
            return string.Empty;

        var element = parent.Element(ns + name);
        if (element == null)
            return string.Empty;

        return element.Value;
    }
    
    public static IEnumerable<dynamic> ItemSearch(string keywords)
    {
        var uri = GenerateUri(new { 
            Operation = "ItemSearch", 
            SearchIndex = "Books", 
            Keywords = keywords 
        });
        var response = Utility.WebRequest(uri);
        var ns = AwsSetting.Namespace;
        var xml = XDocument.Parse(response).Descendants(ns + "Item");
        
        return from item in xml
               let attrs = item.Element(ns + "ItemAttributes")
               select DynamicExpandoObject(model =>
               {
                   model.ASIN = ElementValue(item,"ASIN");
                   model.DetailPageURL = ElementValue(item, "DetailPageURL");
                   model.Author = ElementValue(attrs, "Author");
                   model.Manufacturer = ElementValue(attrs, "Manufacturer");
                   model.Title = ElementValue(attrs, "Title");
               });
    }
}

サンプル実装を参考にした(というか、もうそのまま持ってきた)のはUrlEncodeするコードだけで。RFC3986に準拠させるための文字列置換だからロジックなんて無いのでいいかなと思いまして。後はちゃんとスクラッチ。無駄にスクラッチ。いや、もう、ホント、だからなに?

アクセスキーIDとシークレットアクセスキーは自分でAmazonから取得したものを使ってね!

取得の仕方にかなり戸惑ったけど、AWSに登録してアカウントページからセキュリティ証明書のページにいけば載ってたよ。APIの詳細はDocumentation Archive : Amazon Web Servicesからドキュメントダウンロードして確認しつつ作ったけど、改行コードの部分が"\n"だけでいいことに気がつくのにすげー時間かかった。

とりあえず、BooksカテゴリのItemSearch APIだけを実装してるけど、いくらでも追加しやすいように作ったから、他のAPIも実装してみるといいかも。あと今回はキャッシュも使ってないけど、キャッシュ組み込みはしたほうがいいかな~、どうかな~。同一キーワードで何度も検索する必要がほとんど無い、よね?

~/amazon.cshtml

@{  
    Layout = "~/_SiteLayout.cshtml";
    Page.Title = "Amazon書籍検索";
}
<form method="post">
    キーワード:
    @Html.TextBox("keywords")
    <input type="submit" value="検索" />
</form>

@if (IsPost)
{
    var keywords = Request["keywords"];
    <ul>
    @foreach (dynamic item in Aws.ItemSearch(keywords)) { 
        <li>
            <a href="@item.DetailPageURL">@item.Title</a>
            <div>@item.Author / @item.Manufacturer</div>
        </li>
    }
    </ul>
}

んで、表示ページのコード。特に変わったことはしてないです。

“ONE PIECE”と入れて実行したのが↓コレ。

aws

もちろんASINでも検索できる。API仕様がそうだから。

"ワンピース"で検索してみる。

aws2

ちゃんと動いてるね。よしよし。これで4 Helpers。あ、WebRequestをヘルパーにしたから5個ってことでいいっすかね。

もちろんASP.NET MVCでも動くよ。

ちなみにヘルパーはロジックが入るので、Visual Studio 2010で開発したほうが楽かも。使う分にはコピペしちゃえばいいからWebMatrixで十分かな?

今の気持ちを表現するならこれしかないね!

20100105_1650081

すません...。