Amazonフレッシュ　簡単注文ブックマークレット

今回もブックマークレットの記事です

前の記事でwebページのハック手順を書いたので、もうひとつハック事例を書きます。

#作ったもの
[Amazon Fresh](https://amzn.to/2uA7C5y)のお買いものを手助けするブックマークレット

:one: ASINを指定して**お買いものリスト**に登録できる
![2020-02-11_23h48_17.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/9e5b3852-5564-5ce0-ac4a-ec6f51dbedcd.gif)

:two: **お買いものリスト**にある商品を簡単にカートに入れることができる
![2020-02-11_23h50_06.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/2c79475c-8afd-fafe-bf85-ccc06cfdad33.gif)

:three: 「フレッシュで買う」緑のボタンの近くにお買いものリストに入れるボタンを追加する
![2020-02-12_00h09_36.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/f017f063-c674-341d-aa5c-b9e8db9772cb.gif)

##作った背景
友人がAmazonフレッシュで買い物代行みたいなことをしているようで、以下のようなことを日常的にやっているようでした。

20カ所くらいのお届け先に、だいたい15種類の商品の買い物をする


- 1回あたりの買い物は15～20商品くらい
- いつも同じような商品であり、お届け先ごとに変わりもない
- 同じ商品を確実にカートに入れる
- 1回買い物するとカートの中身は空なので、もう一度カートに入れる必要がある



#ハックポイント
Amazonフレッシュは使ったことがありませんでしたので、結構、時間をかけて使えそうな画面や機能を探しました。最終的にお買いものリストをハックすることにしました。なぜ、お買いものリストをハックすると、問題が解決できると判断したのかは、、、省略します。

##お買いものリストに商品を登録する方法

Amazonフレッシュの商品個別ページを見ると、**カートに入れる**のと**お買いものリスト**を見つけることができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/7e3398ca-001d-fe8a-3968-b8fd7b5b178d.png)

実際にお買いものリストに登録してみたら、お買いものリストの画面に遷移しなかったので、裏側でAPI通信をしているのだろうと想像しました。Dev Toolでネットワーク通信をのぞき見してみたら、APIを呼んでいるようだったので、真似することにしました。

APIで商品を登録するには、ASIN（Amazonでの商品の識別子）が必要なので、どうやって入力してもらうのかを考えないといけないのですが、最初のフェーズでは、買いたい商品のASINを知っている前提にしました。事前に調べておく必要があります。

##お買いものリストからカートに入れる方法
こちらも、実際にボタンをクリックしてネットワーク通信を見ていました。こちらも裏側はAPI化されているみたいでした。ただ、カートに入れるアクションは、APIを呼ぶのではなく、スクリプトでHTMLのボタンを叩くことにしました。
理由は、カートに入れたときのアクションがHTML側に実装されていて、こいつに頼るほうが簡単そうだからです。
![2020-02-11_23h36_51.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/1f9f5018-4bfe-6198-9cd0-e1b93f57df1d.gif)

##jQueryがありませんでした
AmazonフレッシュのサイトはjQueryは使ってないようなので、ブックマークレット内部で呼び込みます。いかに手抜きして作るかが大事になので、脱jQueryはできないなぁ。

#コード
今回は250行くらいになりました。

```javascript:amazonfresh_support.js
// ==ClosureCompiler==
// @output_file_name default.js
// @compilation_level SIMPLE_OPTIMIZATIONS
// @language_out ECMASCRIPT_2017
// ==/ClosureCompiler==
javascript:(
    function(f){
        console.log('load jquery');
        var script = document.createElement('script');
        script.src = '//code.jquery.com/jquery-3.4.1.min.js';
        script.onload = function(){
            var $ = jQuery.noConflict(true);
            f($);
        };
        document.body.appendChild(script);
    }(
    function($,undefined){

        var groceryList = [];
        var currentShoppingList = null;
        var token = '';
        const wait = 555;//milsec
        const storageKeyName = 'amaxon_current_shippinglist';

        //お買いものリストだったらフォーム出したり
        if(location.href.match(/www.amazon.co.jp\/afx\/lists\/grocerylists/)){
            var head = document.head.innerHTML;
            if( head.match(/"csrfToken":"([^"]*)"/) ){
                token = RegExp.$1;
                console.log(token);
            }
    
            findShoppingList();
            createLeftNaviForm();
            localStorage.setItem(storageKeyName, JSON.stringify(currentShoppingList));
            return;
        }

        //お買いものリスト外で緑のカートボタンがあるなら
        var spans = $('span[data-fresh-add-to-cart]');
        if( spans.length > 0){
            var json = localStorage.getItem(storageKeyName);
            if(!json){
                alert('お買いものリストが記録されていません');
                return;
            }
            var lastShoppingList = JSON.parse(json);
            console.log(lastShoppingList);
            showAddShoppingListButton(lastShoppingList);
            alert('【'+lastShoppingList.name +'】に商品を追加します');
        }else{
            alert('ブックマークレットの対象ページではありません');
            window.location.href = 'https://www.amazon.co.jp/afx/lists/grocerylists';
            return;
        }
        
        //左ナビにフォーム出すだけ
        function createLeftNaviForm(){
            $('div#left-0')
            .append(
                '<div style="margin:5px">■ASIN<br>' + 
                '<textarea id="_knx_newAsinText" rows="10"></textarea><br>'+
                '<button id="_knx_addListButton" >リストに追加</button>' + 
                '</div>'
            )
            .append(
                '<hr>'+
                '<div><button id="_knx_addCartButton" >商品をカートに入れる</button></div>'
            )
            .append(
                '<div><button id="_knx_showAsinButton" >ASINを表示</button></div>'
            );

            $('button#_knx_addListButton').on('click', function(){addItemToShoppingList()});
            $('button#_knx_addCartButton').on('click', function(){addItemToCart()});
            $('button#_knx_showAsinButton').on('click', function(){showAsin()});
        }

        //お買いものリストの名前とIDをHTMLから探す
        function findShoppingList(){
            $('ul#shopping-list-menu li').each(function(i,e){

                var g = $(e).find('div.shopping-list-nav').first();
                var id = g.attr('id').replace('-shopping-list-display','');
                var name = g.text().trim();
                var o = {id, name};
                groceryList.push(o);

                if( $(e).find('div#selectedListIndicator').length>0 ){
                    currentShoppingList = o;
                    console.log(currentShoppingList);
                }
            });
            console.log('お買いものリスト', groceryList);
        }
        //お買いものリスト内にある商品をHTMLから探す
        function findItems(onlyAvailable){
            var items = [];
            $('div.asin-item-grid div.asinWrapper').each(function(i,e){
                var asin = e.id.replace('-item-container','');
                var img = $(e).find('div.imageRow img').first();
                var name = '';
                if(img.length>0){
                    name = img.attr('alt');
                }

                var available = false;
                if( $(e).find('span#' + asin + '-add-to-cart').length > 0){
                    available = true;
                }
                items.push( {asin, name, available} );
            });
            console.log('all items :',items);

            if( onlyAvailable ){
                items = items.filter( i => i.available);
            }

            return items;
        }

        //カートに入れる（画面上のボタンを順番に押すだけ）
        async function addItemToCart(){
            var items = findItems(true);
            var btns = [];
            for(i in items){
                console.log(items[i].asin);
                var span = $('span#' + items[i].asin + '-add-to-cart');
                if(span.is(':visible')){
                    btns.push(span);
                }else{
                    console.log('invisible cart button %s', items[i].asin);
                }
            }
            if(confirm(btns.length + 'の商品をカートに入れますか？')){
                for(i in btns){
                    btns[i].click();
                    await new Promise(resolve => setTimeout(resolve, wait));
                }
                alert('カートに入れました');
            }
            console.log('end of addCart');
        }

        //shoppingListにASINを登録する
        async function addItemToShoppingList(){
            var items = findItems(false);
            var existedAsins = [];
            for(i in items){
                existedAsins.push(items[i].asin);
            }
            var textList = $('textarea#_knx_newAsinText').val().split("\n");
            var newAsins = cleanList(textList, existedAsins);

            $('textarea#_knx_newAsinText').val(newAsins.join("\n"));

            if(newAsins.length==0){
                alert('追加する商品が指定されていないです');
                return;
            }
            if( confirm(newAsins.length +'個の商品を買い物リストに追加しますか？')){
                var added=0;
                for(i in newAsins){
                    $('h1#shopping-list-title').text( newAsins[i] + ' (' + (parseInt(i)+1) + '/' + asinList.length + ')' );
                    var apiResult = await callAddItemAPI(currentShoppingList.id,  newAsins[i]);
                    console.log(apiResult);
                    await new Promise(resolve => setTimeout(resolve, wait));
                    added++;
                }
                alert(added + '商品を追加しました');
                if(added>0){
                    window.location.reload(true);
                }
            }
        }
        //AmazonのAPIを呼び出す
        async function callAddItemAPI(shoppingListID, asin){
            const headers = {
                'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
            };
            const body = "listID="+encodeURIComponent(shoppingListID) + '&asin='+encodeURIComponent(asin);
            var response = await fetch('https://www.amazon.co.jp/afx/lists/json/shoppinglists/additem', 
                {method:'POST', 'headers':headers, 'body':body, credentials:'same-origin'}
            );

            var json = await response.json();
            return json;
        }
        
        //
        function showAddShoppingListButton( list ){
            $('span[data-fresh-add-to-cart]').each(function(index){
                var data = JSON.parse($(this).attr('data-fresh-add-to-cart'));
                console.log(data.asin);
                $(this).parent().append(
                    '<button class="_knx_addListButton" data-asin="'+data.asin+'" data-list="'+list.id+'">買いものリストへ</button>'
                );
            });
            $('button._knx_addListButton').on('click', async function(){
                var apiResult = await callAddItemAPI(this.dataset.list, this.dataset.asin);
                if(apiResult.successful){
                    $(this).text('追加しました');
                }
            });
        }

        //ASIN表示
        function showAsin(){
            var items = findItems(false);
            var asins = [];
            
            for(var i in items){
                asins.push(items[i].asin);
            }
            var tabbed = asins.join("\t");
            console.log('--ASIN--');
            console.log(tabbed);

            var html = '';
            for(var i in items){
                html += items[i].asin + ' ' + items[i].name + '<br>';
            }

            var w = window.open('','amazonfresh','width=750,height=300,');
            w.document.write(html);
        }
        function cleanList(inputList, nowList){
            var cleaned = inputList
            //スペースは除去したあとに
            .map( v => v.replace(/\s+/g, "") )
            //空行は除去
            .filter( v => v!='')
            //配列内の重複も除去
            .filter(　(v, i, self) => self.indexOf(v) === i)
            //nowListにあるものも除去
            .filter( v => {
                if(nowList.indexOf(v)>=0){
                    console.log('duplicated %s', v);
                    return false;
                }
                return true;
            });
            console.log(cleaned);
            return cleaned;
        }
    })
)

```

コードはこちらに
https://github.com/kanaxx/bookmarklet/blob/master/amazon/amazonfresh_support.js


#コードの解説
作ったコードをちょっとだけ読んでみます。そんなに難しいコードでもないと思いますが、今時jQueryに頼り切ったコードです。

:warning: 2020年2月時点のAmazonのHTMLを元にしています。HTMLの構造が変わると動かなくなることはあると思います。

##起動直後の処理
このブックマークレットは、2種類の起動モードがあります。
1つは、:one:お買いものリスト上での起動、もう1つが:two:Amazonの商品一覧での起動です。

判定を正確にやるのは難しいですが、
:one:URLが`www.amazon.co.jp/afx/lists/grocerylists`なら**お買いものリストでの実行**とみなし、左ナビゲーションに追加のフォームを出します。

:two: そうでなくて緑の<フレッシュで買う>ボタンがある場合は、ページからお買いものリストに入れるボタンを出します。画面に緑のボタンがあるかどうかは、`<span data-fresh-add-to-cart='***'>`が存在するかどうかでの判定しているので、HTMLのパターンが変わると作りなおしが発生します。


##お買いものリストの名前とIDを探す
左ナビゲーションにフォームを出すときには、お買いものリストの詳細が必要になるため、画面のこの部分から情報を抜き出します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/1183be25-627b-adb4-4780-90235ea07782.png)

`findShoppingList`関数でやっている処理です。

具体的には、左上のリストが並んでいる\<ul>\<li>のパターンを探し、
`<ul id="shopping-list-menu"><li></li><li></li><li></li></ul>`

\<li>中にある\<div class="shopping-list-nav">を探し、IDと名前を抜き出しています。


##お買いものリストに入っている商品を探す
この部分の繰り返しをHTMLから読み取ります。これも繰り返しパターンなので割とやりやすいです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/5b1b93da-dd1e-d4ce-ba55-58bc7e8d1923.png)

`findItems`関数の処理です。これもHTMLからパターンで取り出しています。

`<div class="asin-item-grid">` `<div class="asinWrapper">` (div.asin-item-grid div.asinWrapper)　のタグで囲まれた繰り返しの中から、商品名、商品が売っているかどうかを商品ごとに抜き出していきます。
売っているかどうかは、緑のボタン\<span id="ASIN-add-to-cart">があるか、無いかで判定しています。

##カートに入れる機能
`addItemToCart`の関数の処理です。
カートに入れる処理をコードで代行するのではなく、緑のボタンを押す処理をしているだけです。一気に押すと拒否られるので、ちょっとだけ間隔を置くようにしています。内部的にPromiseを使っているのでasync functionで定義します。

##たくさんのASINをお買いものリストに入れる
商品を一括で、お買いものリストに入れるための機能です。
`addItemToShoppingList`の処理です。

複数のASINを改行区切りでテキストボックスに入れてもらい、「リストに追加」ボタンで起動します。テキストボックスの値を読みほどいて、お買いものリストに入れます。テキストボックス内の重複、空行、すでにお買いものリストにあるものを入れないなどのクリーニングをしたうえで、AmazonのAPIを（勝手に）呼び出しています。

AmazonのAPIは、`callAddItemAPI` で呼ぶようにしています。
x-www-form-urlencodedで
listID=\*\*\*&asin=\*\*\*

ASINを知っていれば使える処理なのですが、ASINを知るのもなかなか大変ですけどね。

##簡単にお買いものリストに登録する機能
:two: の機能です。Amazonフレッシュのトップページに置いてあるカルーセルだと、こんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168315/48adf17b-d410-b6c4-2e9b-02473b2a18e1.png)
`showAddShoppingListButton`の処理です。

これはページのURLパターンとかを見ずに、緑色のボタンがあれば、その下に新しいボタンを差し込むだけです。ボタン探しは、`span[data-fresh-add-to-cart]`セレクタで判定しているので、ボタンデザインの変更には弱いです。差し込むボタンには、`data-asin`と`data-list`に値を埋めこみ、一緒にクリックハンドラもセットします。

ASINを調べずに、お買いもの入りストに入れることができます。
ただ、お買いものリストが複数あるときに、どのお買いものリストに入れるかを選択させるのは面倒だったので、**最後にブックマークレットを動かしたお買いものリスト**を対象としています。お買いものリスト上でブックマークレットを起動するたびに(:one:の処理をするたびに）、localStorageにデータを書き残すようにしています。
（手抜き）

##ASINのリストを作り直す機能
お買いものリストに入っている商品のASINコードを再表示する機能です。どこかにメモ残しておきたい場面を想定してあります。おまけ機能ですね。

`showAsin`の関数でやっている処理ですが、これも難しいことはないです。
window.openで新しいタブを開いて、document.writeでHTMLな文字列を突っ込んで表示しています。よく使うブックマークレットのアウトプットの形です。


#まとめ
ブックマークレットといえど、これくらいの処理を組み込むことは実現できます。
弱点は、

- ページの再読み込みをすると消えてしまうので、再度ブックマークレットのコードを実行する必要があること 
- ページ間のデータの引き継ぎができないので`localStorage`などを上手に使う必要があること
 
まぁ、最大の弱点はHTMLの変更されると動かなくなるので、ある程度メンテすることを覚悟の上で作らないと泣きます。

ここまでくると、ブックマークレットよりはChromeのアドオンにするべきだなと思いますね。
ブックマークレッターの上位職はアドオン開発者ということは気が付いています。そろそろクラスチェンジしようかな。