注文金額はどのように集計されているのか?

Amazonの注文履歴が過去から現在まですべて集計されてしまうという、あのブックマークレットは衝撃的であった。こんな風にブラウザの世界を自在に操作できるJavaScriptって素晴らしい。でも、このブックマークレットがどんな仕組みで集計しているのか、未だすべては理解できていない。(特にDeferredとか)コードの流れ追跡しながら調べてみた。

URL欄のコード

  • 改行なしだと読み難いので、セミコロンで改行を入れてみた。
  • そしてsrcのURLは、フォークした自分のgistに変更している。
javascript:
  (function(){
    var d=document;
    var s=d.createElement('script');
    s.src='https://gist.github.com/zarigani/5718444/raw/eaf1da1434a3d620a93779da2b33fbefd91e6535/aitter.js;
    d.body.appendChild(s)
  })();
  • URL欄に入力したjavascript:に続けたコードは、JavaScriptとして実行される。
  • 上記のコードは、現在のページのbodyタグ内にscriptタグを追加してくれるのだ。
<html>
  <head>...</head>
  <body>...</body>
</html>
  • 仮に上記のようなHTMLソースだったとすると、JavaScript実行後は以下のようになるのだ。
<html>
  <head>...</head>
  <body>...
    <script src='https://gist.github.com/zarigani/5718444/raw/eaf1da1434a3d620a93779da2b33fbefd91e6535/aitter.js' ></script>
  </body>
</html>
  • つまり、src=に続くURLのaitter,jsが読み込まれて実行されるのだ。
  • 実際の金額の集計は、gist置かれたこのaitter,jsに委ねられている。

gistのコード

  • こちらはフォークしたgistコード。
  • 実行すると入力ダイアログなしで、いきなり注文履歴のすべての年間を集計する仕様。
  • コメントも入れてみた。


1: (function(){
2: // このブックマークレット内で共有する変数定義
3: var total = {};
4: var year = '2012';
5:
6: // 処理中のオーバーレイ表示の追加と変数の初期化
7: function init() {
8: $('<div/>').css({
9: position: 'fixed',
10: left: 0,
11: top: 0,
12: width: '100%',
13: height: '100%',
14: zIndex: 1000,
15: backgroundColor: 'rgba(0,0,0,.7)',
16: color: '#fff',
17: fontSize: 30,
18: textAlign: 'center',
19: paddingTop: '15em'
20: }).attr('id', '___overlay').text('Amazonいくら使った?').appendTo('body');
21: year = $('#orderFilter option:last').val().match(/[0-9]/g).join('');
22: year = Number(year);
23: total[year] = 0;
24: main(0);
25: }
26:  
27: // 年ごとの注文金額を集計して、最後に総合計を加えて出力する
28: function main(num) {
29: var progress = load(num);
30: $('#___overlay').text(year+'年の集計中… / '+(num+1)+'ページ目');
31: progress.done(function(price){
32: total[year] += price;
33: main(num+1);
34: }).fail(function(){
35: if(new Date().getFullYear() > year) {
36: year++;
37: total[year] = 0;
38: main(0);
39: } else {
40: var txt = 'あなたは\n';
41: var _total = 0;
42: $.each(total, function(year, yen){
43: txt += year + ' 合計' + addFigure(yen) + '円分\n';
44: _total += yen;
45: });
46: txt += '総計' + addFigure(_total) + '円分\n';
47: alert(txt + 'の買い物をAmazonでしました!');
48: $('#___overlay').remove();
49: }
50: });
51: }
52:  
53: // 注文履歴ページごとのprice属性の金額を集計する
54: function load(num) {
55: var df = $.Deferred();//「処理の引き延ばしを利用するよ」
56: var page = get(num);
57: page.done(function(data){
58: var dom = $.parseHTML(data);
59: var _total = 0;
60: $(dom).find('.price').each(function(){
61: _total += (Number($(this).text().match(/[0-9]/g).join('')));
62: });
63: if(_total === 0) df.reject();//「ごめんダメだったmain関数の.fail(function(){
64: else df.resolve(_total);//「はい終わったぜ!」main関数のprogress.done(function(price){
65: });
66: return df.promise();//「後でなんか返すからちょっと待っててよ」
67: }
68:  
69: // year年のnumページの注文履歴を取得する
70: function get(num) {
71: var df = $.Deferred();//「処理の引き延ばしを利用するよ」
72: $.ajax({
73: url: 'https://www.amazon.co.jp/gp/css/order-history/?orderFilter=year-'+year+'&startIndex='+num*10,
74: success: function(data){
75: df.resolve(data);//「はい終わったぜ!」load関数のpage.done(function(data){
76: }
77: });
78: return df.promise();//「後でなんか返すからちょっと待っててよ」
79: }
80:  
81: // 桁区切りして返す
82: // addFigure('1234567890') ---> 1,234,567,890
83: function addFigure(str) {
84: var num = new String(str).replace(/,/g, "");
85: while(num != (num = num.replace(/^(-?\d+)(\d{3})/, "$1,$2")));
86: return num;
87: }
88:  
89: // jqueryライブラリを追加して、init関数から実行する
90: if(typeof $ !== 'function') {
91: var d=document;
92: var s=d.createElement('script');
93: s.src='//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js';
94: s.onload=init;
95: d.body.appendChild(s);
96: } else {
97: init();
98: }
99: })();

https://gist.github.com/zarigani/5718444#file-aitter-js

(function(){ ...処理コード... })(); は何をしているのか?


1: (function(){
...処理コード...
99: })();

  • URLに貼り付けるjavascript:で始まるコードも、gistのaitter,jsも、括弧の連続技であるfunctionで囲まれている。
  • これは即時関数と呼ばれる書き方だそうだが、その詳細は素晴らしい解説をしている以下のサイトに委ねる。(感謝!)
  • つまり自分の理解したところでは、即時関数の中に書いたコードは、その外側のコード世界に影響を与えないのだ。
  • ブックマークレットは、読み込まれた既存のページに付加されて実行されるJavaScriptコードである。
  • 既存のページでもJavaScriptが利用されていると、うっかりすると同じ関数名や変数名を使ってしまい、動作がおかしくなる可能性がある。
  • そんな可能性を排除するために、即時関数というカプセルの中に処理コードを書いているのだ。(と思っている)
  • その関数の定義と実行を両方行いたい時のJavaScriptお決まりの書き方なのだ。

変数totalとyear


3: var total = {};
4: var year = '2012';

  • 3行目、4行目で定義されているtotalとyearは、このブックマークレット内で共有される変数である。
  • この即時関数の中であれば、どこに書いたコードからでも参照・変更できるのだ。
  • totalは、year(西暦)をキーとして、西暦ごとの集計を保存しておく連想配列

function init()


7: function init() {
8: $('<div/>').css({
9: position: 'fixed',
10: left: 0,
11: top: 0,
12: width: '100%',
13: height: '100%',
14: zIndex: 1000,
15: backgroundColor: 'rgba(0,0,0,.7)',
16: color: '#fff',
17: fontSize: 30,
18: textAlign: 'center',
19: paddingTop: '15em'
20: }).attr('id', '___overlay').text('Amazonいくら使った?').appendTo('body');
21: year = $('#orderFilter option:last').val().match(/[0-9]/g).join('');
22: year = Number(year);
23: total[year] = 0;
24: main(0);
25: }

  • function init()は、オーバーレイ表示のCSS定義と、変数totalとyearの初期値を設定している。
  • 8〜20行目=オーバーレイ表示のCSS定義
  • 21〜22行目=注文時期の最も過去(最後)の西暦を取得して、それを変数yearに設定。
  • 23行目=連想配列totalを設定。最も過去の西暦が2006だとすると、total[2006]=0;が実行されるのだ。
  • 24行目=以上の初期設定が完了すると、集計処理のメインとなるmain関数を実行。

deferredオブジェクトを利用した処理

function main(num)


28: function main(num) {
29: var progress = load(num);

  • main関数は、引数numに0が渡されて始まる。
  • そして、29行目でいきなりload(num)関数が呼ばれて、処理はload(num)関数に移る。
function load(num)


54: function load(num) {
55: var df = $.Deferred();//「処理の引き延ばしを利用するよ」
56: var page = get(num);

  • 55行目のload(num)関数の始まりは、謎の変数定義から始まる。
var df = $.Deferred();
  • $.Deferred();とは何をしているのか?
  • 実はこの$.Deferred();を理解することで、このブックマークレット全体の流れが納得できる。
  • でも、次の56行目ではget(num)関数が実行されてしまう。
  • よって、get(num)関数から詳細に追跡していく。
function get(num)


70: function get(num) {
71: var df = $.Deferred();//「処理の引き延ばしを利用するよ」
72: $.ajax({
73: url: 'https://www.amazon.co.jp/gp/css/order-history/?orderFilter=year-'+year+'&startIndex='+num*10,
74: success: function(data){
75: df.resolve(data);//「はい終わったぜ!」load関数のpage.done(function(data){
76: }
77: });
78: return df.promise();//「後でなんか返すからちょっと待っててよ」
79: }

  • 71行目のget(num)関数の始まりも、同じく謎の変数定義がある。
var df = $.Deferred();
  • 直前のload(num)とまったく同じコードである。
  • $.Deferred()は何をしているのかと言えば、jQueryのdeferredオブジェクトを取得しているのだ。
  • deferredオブジェクトは、非同期の処理の完了を待って実行する仕組みを提供してくれる。
  • 次の72行目の$.ajaxは、非同期で注文履歴のページを取得するのだが、
  • 非同期なので$.ajaxの完了を待たずに、get(num)関数の最後の行まで処理が進んでしまう。
  • そこで78行目で、deferredオブジェクトのdf.promise();を返している。
  • df.promise()は、「処理が終わるまで待っていてね」という合図のオブジェクトだ。
  • つまり、$.ajax({ ... });ブロックの処理が完了するまで、呼び出し元function load()は待機するのだ。
function load(num)
  • その後、$.ajaxの処理が完了した時、df.resolve(data);が実行されると、待機中だったfunction load()の処理が再開される。
  • 具体的には、56行目var page = get(num);から処理が再開されるのだ。


56: var page = get(num);
57: page.done(function(data){
58: var dom = $.parseHTML(data);
59: var _total = 0;
60: $(dom).find('.price').each(function(){
61: _total += (Number($(this).text().match(/[0-9]/g).join('')));
62: });
63: if(_total === 0) df.reject();//「ごめんダメだったmain関数の.fail(function(){
64: else df.resolve(_total);//「はい終わったぜ!」main関数のprogress.done(function(price){
65: });
66: return df.promise();//「後でなんか返すからちょっと待っててよ」
67: }

  • df.resolve(data);は処理が正常に完了した合図なので、57行目からのpage.done()ブロックも実行される。
  • page.done()ブロックでは、取得した注文履歴ページのHTMLから、クラス属性がpriceである金額を集計している。
    • 集計金額が0だったら、df.reject();を実行して、
    • 集計金額がある限り、df.resolve(_total);を実行する
function main(num)
  • deferredオブジェクトのdf.resolve(_total);は、呼び出し元の.done()ブロックを実行する。
    • .done()ブロックは、注文履歴のページが続く限り集計を繰り返す。


31: progress.done(function(price){
32: total[year] += price;
33: main(num+1);

  • そしてもう1つ、df.reject();は、呼び出し元の.failブロックを実行する。
    • .failブロックは、現在の西暦になるまで、1年ずつ、年間集計を計算する。
    • すべての西暦を集計し終えたら、alertダイアログで結果を出力している。


34: }).fail(function(){
35: if(new Date().getFullYear() > year) {
36: year++;
37: total[year] = 0;
38: main(0);
39: } else {
40: var txt = 'あなたは\n';
41: var _total = 0;
42: $.each(total, function(year, yen){
43: txt += year + ' 合計' + addFigure(yen) + '円分\n';
44: _total += yen;
45: });
46: txt += '総計' + addFigure(_total) + '円分\n';
47: alert(txt + 'の買い物をAmazonでしました!');
48: $('#___overlay').remove();
49: }
50: });
51: }


なるほど、なるほど。ようやくコードの流れがすべて理解できた。
これを雛形に様々なページを集計できるのだ!(モロ屋さんに感謝!)