Hatena::ブログ(Diary)

ザリガニが見ていた...。 このページをアンテナに追加 RSSフィード

2014-08-14

JavaScriptを圧縮・整形するコマンド作り

最近、ブックマークレットを書く時には、Closure Compilerをよく使う。Closure Compilerは、Googleが提供しているJavaScriptコードの圧縮・整形サービスの一つである。

圧縮といってもzip圧縮などとは違う。正確には、コンピュータが実行しやすいように最適化しているのだ。

  • コメントや空白文字を削除したり、
  • 使っていない関数を削除したり、
  • 変数名や関数名を短縮したり、
  • 最適化レベルによっては、関数の中身のコードを展開することもある。

以上の作業を機械的に行って、最適化されたコードを返してくれる。人間にとってはめちゃくちゃ読み難いコードだけど、コンピュータにとっては無駄のない、実行しやすいコードとなるのだ。

逆に、そのようなめちゃくちゃ読み難いワンライナーにインデントや改行を追加して、少しでも人間が解釈しやすいコードに整形することもできる。(短縮されてしまった変数名や関数名、展開されてしまった関数コードなどは元に戻らないが)他人の書いたブックマークレットを読む時に、とても重宝している。

ブックマークレットを作り始めると、いつも上記のWebサービスのページを開いて、コピー&ペーストを繰り返していた...。コピー&ペーストは偉大な発明である。しかし、頻繁に繰り返していると、いいかげん面倒になってきた。どうにかしたい。

コード比較

  • ちなみに、ブックマークレットはワンライナーにしなくてもちゃんと実行される。
  • 改行・インデントありのコードでも、ブックマークするとURIエンコードされる。

  • 元のコード
javascript:
(function(d,f,s){
  s=d.createElement('script');
  s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';
  s.onload=function(){f()};/* <---onload属性を追加 */
  d.body.appendChild(s);

  f=function(){
    /* 内部コード */
    alert(typeof CryptoJS);
    alert(CryptoJS.AES.encrypt('hello','1234'));
  };
})(document)

  • URIエンコードされたコード
javascript:(function(d,f,s){%20%20s=d.createElement('script');%20%20s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';%20%20s.onload=function(){f()};/*%20<---onload属性を追加%20*/%20%20d.body.appendChild(s);%20%20f=function(){%20%20%20%20/*%20内部コード%20*/%20%20%20%20alert(typeof%20CryptoJS);%20%20%20%20alert(CryptoJS.AES.encrypt('hello','1234'));%20%20};})(document)

  • Closure Compilerの最適化(WHITESPACE_ONLY)
javascript:(function(d,f,s){s=d.createElement("script");s.src="//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js";s.onload=function(){f()};d.body.appendChild(s);f=function(){alert(typeof%20CryptoJS);alert(CryptoJS.AES.encrypt("hello","1234"))}})(document);

Closure CompilerのAPIを使う

Closure CompilerはWebページのGUIサービスだけでなく、Web APIも提供してくれている。

  • その使い方は、けっこうシンプル。
  • 以下コードはPythonなのだけど、
#!/usr/bin/python2.4

import httplib, urllib, sys

# Define the parameters for the POST request and encode them in
# a URL-safe format.

params = urllib.urlencode([
    ('js_code', sys.argv[1]),
    ('compilation_level', 'WHITESPACE_ONLY'),
    ('output_format', 'text'),
    ('output_info', 'compiled_code'),
  ])

# Always use the following value for the Content-type header.
headers = { "Content-type": "application/x-www-form-urlencoded" }
conn = httplib.HTTPConnection('closure-compiler.appspot.com')
conn.request('POST', '/compile', params, headers)
response = conn.getresponse()
data = response.read()
print data
conn.close()
Communicating with the Closure Compiler Service API  |  Closure Compiler  |  Google Developers

  • Ruby育ちなので書き直してみた。
#!/usr/bin/ruby

require 'net/http'
require 'uri'

# Define the parameters for the POST request and encode them in
# a URL-safe format.

params = {js_code:ARGV[0], 
          compilation_level:'WHITESPACE_ONLY',
          output_format:'text',
          output_info:'compiled_code'}

# Always use the following value for the Content-type header.
url = URI('http://closure-compiler.appspot.com/compile')
response = Net::HTTP.post_form(url, params)
data = response.body
puts data

  • さっそくテストしてみると、うまく動いている感じ!
$ ruby compile.rb 'alert("hello");// This comment should be stripped'
alert("hello");

コマンドにする

  • RubyからClosure Compilerを利用する仕組みを理解できたので、
  • あとはコマンドらしく、オプション指定できるようにすればいいのだ!
  • コマンドの作り方について、以前調べたことがある。
  • オプション解析の機能を追加して、以下のようなコードを書いてみた。
#!/usr/bin/ruby

require 'optparse'
require 'net/http'
require 'uri'

compilation_level_names = Hash.new{|h,k| k}
compilation_level_names.merge!({'1'=>'WHITESPACE_ONLY', '2'=>'SIMPLE_OPTIMIZATIONS', '3'=>'ADVANCED_OPTIMIZATIONS'})
option_hash = {}
OptionParser.new do |opt|
  opt.on('-l','--compilation_level=STR|NUM', 'WHITESPACE_ONLY | SIMPLE_OPTIMIZATIONS | ADVANCED_OPTIMIZATIONS (Default:WHITESPACE_ONLY)', 
                                             '1               | 2                    | 3                      (Default:1              )') {|v| option_hash[:compilation_level] = compilation_level_names[v]}
  opt.on('--output_format=STR', 'text | xml | json (Default:text)') {|v| option_hash[:output_format] = v }
  opt.on('--pretty_print', 'Add new line and indent for readable code.') {|v| option_hash[:formatting] = 'pretty_print'}
  opt.on('Example:', 
         '    cat FILE_PATH | js-compile.rb --compilation_level=WHITESPACE_ONLY --pretty_print', 
         '    cat FILE_PATH | js-compile.rb -l1 --pretty_print', 
         '    cat FILE_PATH | js-compile.rb --pretty_print', 
         '    js-compile.rb --pretty_print "`cat FILE_PATH`"', 
         'The above commands output same compiled codes.')
  
  opt.parse!(ARGV)
end
#p option_hash

url = URI('http://closure-compiler.appspot.com/compile')
input =  URI.decode(ARGV[0] || STDIN.gets(nil))
params = {js_code:input, 
          compilation_level:'WHITESPACE_ONLY',
          output_format:'text',
          output_info:'compiled_code'}
res = Net::HTTP.post_form(url, params.merge(option_hash))
puts res.body
STDERR.puts "", "Before: #{input.length}", "After : #{res.body.length}", "Rate  : #{res.body.length.to_f / input.length.to_f * 100}"
  • 以上のコードをjs-compile.rbとして保存した。
  • 実行権限を追加しておいた。
$ chmod a+x js-compile.rb
optparseの使い方の新発見(自分の中で)
  • 1文字オプションとロングオプションは同時に設定できる。
    • ロングオプション側で引数設定しておけば、1文字オプション側の引数設定は不要になる。
    • 同様に、1文字オプション側で引数設定しておけば、ロングオプション側の引数設定は不要になる。
  • 4番目以降の文字列は、オプション説明項目の2行目、3行目となる。
opt.on('-l', '--long=NUM', 'オプションの説明1行目', 'オプションの説明2行目', ...){処理}

  • オプション文字を書かなければ、単なるhelp解説になる。
    • opt.on内のすべてのテキスト先頭が-で始まらなければ、
    • それはhelpの時インデントなしで表示される解説となる。
  opt.on('Example:', 
         '    cat FILE_PATH | js-compile.rb --compilation_level=WHITESPACE_ONLY --pretty_print', 
         '    cat FILE_PATH | js-compile.rb -l1 --pretty_print', 
         '    cat FILE_PATH | js-compile.rb --pretty_print', 
         '    js-compile.rb --pretty_print "`cat FILE_PATH`"', 
         'The above commands output same compiled codes.')

  • 実際にヘルプ表示してみると、こんな感じの出力になる。
$ js-compile.rb -h
Usage: js-compile [options]
    -l, --compilation_level=STR|NUM  WHITESPACE_ONLY | SIMPLE_OPTIMIZATIONS | ADVANCED_OPTIMIZATIONS (Default:WHITESPACE_ONLY)
                                     1               | 2                    | 3                      (Default:1              )
        --output_format=STR          text | xml | json (Default:text)
        --pretty_print               Add new line and indent for readable code.
Example:
    cat FILE_PATH | js-compile.rb --compilation_level=WHITESPACE_ONLY --pretty_print
    cat FILE_PATH | js-compile.rb -l1 --pretty_print
    cat FILE_PATH | js-compile.rb --pretty_print
    js-compile.rb --pretty_print "`cat FILE_PATH`"
The above commands output same compiled codes.

以上は、断片的な発見である。

JavaScriptコードを引数だけでなく、標準入力からも受け取れるようにした
input =  URI.decode(ARGV[0] || STDIN.gets(nil))
  • opt.parse!(ARGV)を実行すると、ARGVの配列からオプションがすべて取り除かれる。
  • ARGV[0]には、オプションを取り除いた後の第1引数が代入されている。
  • 標準入力からテキストデータ全体は、STDIN.gets(nil))で取得できる。
    • 通常、getsは引数に指定した文字で区切って1行ずつ読み込むが、
    • nilを指定すると区切りなしと解釈され、全体を一気に読み込む。
  • URIデコードしておくことで、ブックマークレット内のURIエンコードされたJavaScriptでも正常に処理できるようにした。
コードの圧縮率などの付加情報を標準エラーに出力するようにした。
  • STDERR.putsによって、標準エラーへの出力となる。
STDERR.puts "", "Before: #{input.length}", "After : #{res.body.length}", "Rate  : #{res.body.length.to_f / input.length.to_f * 100}"
  • こうしておくことで、標準出力にはJavaScriptコードのみが出力されるので、パイプやリダイレクトでコードのみを取り出せるのだ。

使い方

  • 不要な空白文字のみ取り除く最適化をする。
$ cat FILE_PATH | js-compile.rb --compilation_level=WHITESPACE_ONLY

  • ロングオプション--compilation_levelは、1文字オプション-lと同じ。
  • また、3つのcompilation_levelは、1から3の番号に対応している。
123
WHITESPACE_ONLY SIMPLE_OPTIMIZATIONS ADVANCED_OPTIMIZATIONS
  • よって、以下のコードでも、不要な空白文字のみ取り除く最適化となる。
$ cat FILE_PATH | js-compile.rb -l1

  • そもそもWHITESPACE_ONLYはデフォルト設定なので、オプションなしでも不要な空白文字のみ取り除く最適化となる。
$ cat FILE_PATH | js-compile.rb

  • よく使うのは、SIMPLE_OPTIMIZATIONSまで。
$ cat FILE_PATH | js-compile.rb -l2

pbcopy・pbpasteとの組み合わせ
$ pbpaste | js-compile.rb | pbcopy

  • コピーしたJavascriptコードを整形表示する。
$ pbpaste | js-compile.rb --pretty_print
改行すべてを削除して1行にしたい時
$ cat FILE_PATH | js-compile.rb | perl -pe 's/\n//g'

2014-08-11

ブックマークレット実行時に外部ファイルをロードして使う

ブックマークレットの書き方の段階的な発展の仕方の続き。

基本

javascript:
(function(d,s){
  s=d.createElement('script');s.src='//dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js';d.body.appendChild(s);
})(document)

  • Webページのbodyタグに以下のようなscriptタグを追加して、
  • シンプルに、たった一つの外部ファイルをロードしているのだ。
<script src='//dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js'></script>

複数の外部ファイルをロードする

  • では、外部ファイルを二つ以上ロードしたいときはどうするべきか?
  • 方法としては、ロードしたい外部ファイルの数だけscriptタグを追加すればいいはず。

  • 外部ファイル二つなら、コピペして、srcのURLだけ変更すればいいのかもしれない。
javascript:
(function(d,s){
  s=d.createElement('script');s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';d.body.appendChild(s);
  s=d.createElement('script');s.src='//dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js';d.body.appendChild(s);
})(document)

  • では、外部ファイルを三つ以上ロードしたい時も、コピペを繰り返すべきなのか?
  • いや、やめておこう。URLだけ指定すればロードしてくれる、そんな関数が欲しい。
javascript:
(function(d,urls,i,s){
  for(i=0;i<urls.length;i++){s=d.createElement('script');s.src=urls[i];d.body.appendChild(s)};
})(document,['//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js','//dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js'])

これで外部ファイルがいくつ増えても、URLを追加するだけでロードしてくれる!

  • ここまで、外部ファイルをひたすらロードするだけのブックマークレットである。
  • ひたすらロードするだけであっても、何でも出来るはず。
  • 目的のコードを外部ファイルに保存しておけばいいのだ。
  • 外部ファイルの保存場所としては、DropboxのPublicディレクトリなどが使える。

外部ファイルをロードしてからブックマークレット内部のコードを実行する

  • 上記までのように、すべてのコードを外部ファイルにしてしまうのも一つの方法である。
  • でも通常は、ライブラリのみ外部からロードして、目的のコードはブックマークレット内部に書いておきたい。
はじめの一歩
  • そして、最初に思いつくのはこんなコード。(alert部分がブックマークレット内部のコード)
javascript:
(function(d,s){
  s=d.createElement('script');s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';d.body.appendChild(s);

  /* 内部コード */
  alert(typeof CryptoJS);
  alert(CryptoJS.AES.encrypt('hello','1234'));
})(document)
  • しかし、上記のコードを実行してみると、正常には動かない...。
    • 事前準備として、どこかのページを新規に開いておく。
    • キャッシュを空にして、ページを再読み込みしておく。

f:id:zariganitosh:20140809080331p:image:w428


  • 気を取り直して、もう一度、同じページで実行するとちゃんと動く。

f:id:zariganitosh:20140809080449p:image:w428

f:id:zariganitosh:20140809080626p:image:w428


  • 最初に動かなかったのは、気のせいだった訳ではない。
  • CryptoJSが、しっかり"undefined"になっているのだ。
  • おそらく、crypto-jsライブラリのロードが完了する前に、次のalert文の実行が始まってしまったのだ。
setTimeoutを使う
  • 外部ファイルがロードされるタイミングと、内部コードが実行されるタイミングに気を付けて書き直し。
javascript:
(function(d,s){
  s=d.createElement('script');s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';d.body.appendChild(s);

  setTimeout(function(){
    /* 内部コード */
    alert(typeof CryptoJS);
    alert(CryptoJS.AES.encrypt('hello','1234'));
  },1000);
})(document)
  • キャッシュを空にして、ページを再読み込みして、事前準備完了。

今度は、初回から正常に動作した!

  • setTimeoutで、内部コードの実行を1秒遅らせてみたのだ。
  • その間に、crypto-jsのロードが完了して、CryptoJSオブジェクトが利用可能になるのだ。
onloadを使う
  • しかし、必ず1秒待機するのは無駄が多い。
  • あるいは、1秒待機してもロードは未完かもしれない。
  • 本来は、ロードが完了したタイミングで、すぐに内部コードを実行するのがベスト。
  • onloadイベントハンドラを使うと、ロードが完了ししたタイミングで実行できる。
javascript:
(function(d,f,s){
  s=d.createElement('script');
  s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';
  s.onload=function(){f()};/* <---onload属性を追加 */
  d.body.appendChild(s);

  f=function(){
    /* 内部コード */
    alert(typeof CryptoJS);
    alert(CryptoJS.AES.encrypt('hello','1234'));
  };
})(document)

これで、無駄に待機せずに素早く実行できるようになった!

onloadするのは最後だけ
  • 複数のライブラリを読み込む時は、最後のロードだけonloadする。
  • 毎回onloadすると、内部コードの実行が繰り返えされてしまう...。
javascript:
(function(d,f,s){
  s=d.createElement('script');
  s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/sha256.js';
  d.body.appendChild(s);

  s=d.createElement('script');
  s.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';
  s.onload=function(){f()};/* <---onload属性を追加はここだけ */
  d.body.appendChild(s);

  f=function(){
    /* 内部コード */
    alert(CryptoJS.SHA256('1234'));
    alert(CryptoJS.AES.encrypt('hello','1234'));
  };
})(document)
for+onload
  • 複数の外部ファイルをロードするforを使った技と組み合わせてみた。
javascript:
(function(urls,i,s,f){
  for(i=0;i<urls.length;i++){
    s=document.createElement("script");
    s.src=urls[i];
    if(i==urls.length-1){s.onload=function(){f()}};
    document.body.appendChild(s);
  };

  f=function(){
    /* 内部コード */
    alert(CryptoJS.SHA256('1234'));
    alert(CryptoJS.AES.encrypt('hello','1234'));
  };
})(["//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/sha256.js","//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js"])
内部コードの関数を引数渡し
  • 外部ファイルのロード内部コードを書く部分が明確に分かれる所が好き。
javascript:
(function(f,urls,i,s){
  urls=["//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/sha256.js","//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js"];
  for(i=0;i<urls.length;i++){
    s=document.createElement("script");
    s.src=urls[i];
    if(i==urls.length-1){s.onload=function(){f()}};
    document.body.appendChild(s);
  };
})(function(){
  /* 内部コード */
  alert(CryptoJS.SHA256('1234'));
  alert(CryptoJS.AES.encrypt('hello','1234'));
})

jQueryを使う

基本
  • これまでの技を使えば、ブックマークレットでjQueryも使えるはず。
javascript:
(function(f,urls,i,s){
  urls=["//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"];
  for(i=0;i<urls.length;i++){
    s= document.createElement("script");
    s.src=urls[i];
    if(i==urls.length-1){s.onload=function(){f()}};
    document.body.appendChild(s);
  };
})(function(){
  /* 内部コード */
  alert($().jquery);
})
コンフリクト防止
  • jQueryはよく使われるライブラリである。
  • 様々なバージョンがあって、中には互換性のないバージョンもあったりする。
  • また、jQueryやprototypeなどはシンプルに書くため、$変数にそのオブジェクトを代入する。
  • jQueryやprototypeなどを使っているページで上記ブックマークレットを実行するとどうなるか?
  • 困ったことに、現在のページが正常に動作しなくなってしまう恐れがあるのだ...。
  • ブックマークレットがロードしたjQueryによって、$変数が書き換えられてしまうため。

jQuery.noConflict();
    • $変数にjQueryオブジェクトを代入しない。
    • jQuery変数にはjQueryオブジェクトを代入する。

var myQuery=jQuery.noConflict(true);
    • $変数にjQueryオブジェクトを代入しない。
    • jQuery変数にもjQueryオブジェクトを代入しない。(より正確には、jQuery変数を以前の状態に戻す)
    • myQuery変数にjQueryオブジェクトを代入する。

  • jQuery.noConflict(true)を使って、書き直してみる。
javascript:
(function(f,urls,i,s){
  urls=["//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"];
  for(i=0;i<urls.length;i++){
    s= document.createElement("script");
    s.src=urls[i];
    if(i==urls.length-1){s.onload=function(){f(jQuery.noConflict(true))}};
    document.body.appendChild(s);
  };
})(function($){
  /* 内部コード */
  alert($().jquery);
})
  • 内部コードの仮引数として$を指定して、内部コードではローカルな$変数が利用される。
  • ローカルな$変数には、jQuery.noConflict(true)が返すjQueryオブジェクトが代入されるのだ。
    • onload=function(){f(jQuery.noConflict(true))}由来。

これで、既存のjQuery環境や$変数に一切影響を与えず、ブックマークレットでjQueryを利用できるようになる!

すぐに使えるワンライナーの雛形

      • URL = ロードする外部ファイルのURL。
      • 内部コード = ブックマークレット内部に書くコード。(ロードしたライブラリを活用できる)

  • 指定したURLの外部ファイルをロードする。(ひたすらロードするのみ)
javascript:(function(urls,i,s){for(i=0;i<urls.length;i++){s=document.createElement('script');s.src=urls[i];document.body.appendChild(s)};})([/* "URL","URL",... */])

  • 指定したURLの外部ファイルをロードしてから、内部コードを実行する。
    • ブックマークレット内で外部のJavaScriptライブラリを利用したい場合。
javascript:(function(f,urls,i,s){urls=[/* "URL","URL",... */];for(i=0;i<urls.length;i++){s=document.createElement("script");s.src=urls[i];if(i==urls.length-1)s.onload=function(){f()};document.body.appendChild(s)}})(function(){/* 内部コード */})

  • ブックマークレット内でjQueryを利用する。
    • 既存のjQuery環境に、影響を与えない。
    • 既存のグローバルな$変数に、影響を与えない。
javascript:(function(f,s){s=document.createElement("script");s.src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js";s.onload=function(){f(jQuery.noConflict(true))};document.body.appendChild(s)})(function($){/* 内部コード */})

  • ブックマークレット内でjQueryやその他複数のライブラリも利用する。
javascript:(function(f,urls,i,s){urls=["//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"/* ,"URL","URL",... */];for(i=0;i<urls.length;i++){s=document.createElement("script");s.src=urls[i];if(i==urls.length-1)s.onload=function(){f(jQuery.noConflict(true))};document.body.appendChild(s)}})(function($){/* 内部コード */})

2014-08-07

ログイン情報を自動入力するブックマークレットの作り方

Webページの入力情報を読み取る

f:id:zariganitosh:20140723100104p:image:w450


  • 上記ページで以下のAppleScriptを実行すると...

 tell application "Safari"
   activate
   do JavaScript "(function(d, s, r, url_key, inputs, options, i){
 url_key=d.location.protocol.replace(/[^A-Za-z]/g,'_')+'__'+d.location.hostname.replace(/[^A-Za-z]/g,'_')+d.location.pathname.replace(/\\W/g,'_');
 inputs=d.getElementsByTagName('input');
 options=d.getElementsByTagName('option');
 for(i=0;i<inputs.length;i++){
   if(inputs[i].type!='hidden'&&inputs[i].type!='image'&&inputs[i].type!='button'){
     s.push(eval('('+'{type: \"'+inputs[i].type+'\", id: \"'+inputs[i].id+'\", name: \"'+inputs[i].name+'\", value: \"'+inputs[i].value+'\", checked: '+inputs[i].checked+', index: \"'+i+'\"}'+')'));
   }
 }
 for(i=0;i<options.length;i++){
   if(options[i].selected){
     s.push(eval('('+'{type: \"option\", name: \"'+options[i].name+'\", value: \"'+options[i].value+'\", selected: '+options[i].selected+', index: \"'+i+'\"}'+')'));
   }
 }
 r[url_key]=s;
 return r;
 })(document,[],{})" in document 1
 end tell


  • URLをキーとした、以下のレコードが返ってくる。

{
  https___healthcare_mb_softbank_jp_pc_assets_setup_web_explanation_input_php:{
    {checked:false, |id|:"telno3", value:"00012345678", type:"text", |name|:"", |index|:"0"}, 
    {checked:false, |id|:"passwd", value:"1234", type:"password", |name|:"passwd", |index|:"1"}, 
    {|index|:"3", value:"4", type:"option", |name|:"undefined", selected:true}
  }
}

  • URLをキーにして、以下3項目のフォームの情報を配列として保持している。
    • 体組成計の電話番号(テキストフィールド)
    • ユーザー番号(プルダウンリスト)
    • 暗証番号(パスワードフィールド)

自動入力するJavaScript

  • 上記AppleScriptレコードは、以下のようなJSONに変換できる。
{
  https___healthcare_mb_softbank_jp_pc_assets_setup_web_explanation_input_php:[
    {checked:false, id:"telno3", value:"00012345678", type:"text", name:"", index:"0"}, 
    {checked:false, id:"passwd", value:"1234", type:"password", name:"passwd", index:"1"}, 
    {index:"3", value:"4", type:"option", name:"undefined", selected:true}
  ]
}

  • そして、上記JSONを利用して、自動入力するJavaScriptコードを考えてみた。
(function(){


var json={
  https___healthcare_mb_softbank_jp_pc_assets_setup_web_explanation_input_php:[
    {checked:false, id:"telno3", value:"00012345678", type:"text", name:"", index:"0"}, 
    {checked:false, id:"passwd", value:"1234", type:"password", name:"passwd", index:"1"}, 
    {index:"3", value:"4", type:"option", name:"undefined", selected:true}
  ]
};
var url_key=document.location.protocol.replace(/[^A-Za-z]/g,'_')+'__'+document.location.hostname.replace(/[^A-Za-z]/g,'_')+document.location.pathname.replace(/\W/g,'_');
var j=json[url_key];

for(var i=0;i<j.length;i++){
  if(j[i].type=='text'||j[i].type=='password'){
    if(document.getElementsByName(j[i].name).length > 0){
      document.getElementsByName(j[i].name)[0].value=j[i].value
    }else{
      document.getElementById(j[i].id).value=j[i].value
    }
  }else if(j[i].type=='radio'||j[i].type=='checkbox'){
    document.getElementsByTagName('input')[j[i].index].checked=j[i].checked
  }else if(j[i].type=='option'){
    if(document.getElementsByTagName('option')[j[i].index].value==j[i].value){
      document.getElementsByTagName('option')[j[i].index].selected=j[i].selected
    }
  }
}
alert('auto_login works!');


})()

javascript:(function(){for(var b={https___healthcare_mb_softbank_jp_pc_assets_setup_web_explanation_input_php:[{checked:!1,id:"telno3",value:"00012345678",type:"text",name:"",index:"0"},{checked:!1,id:"passwd",value:"1234",type:"password",name:"passwd",index:"1"},{index:"3",value:"4",type:"option",name:"undefined",selected:!0}]}[document.location.protocol.replace(/[^A-Za-z]/g,"_")+"__"+document.location.hostname.replace(/[^A-Za-z]/g,"_")+document.location.pathname.replace(/\W/g,"_")],a=0;a<b.length;a++)"text"==b[a].type||"password"==b[a].type?0<document.getElementsByName(b[a].name).length?document.getElementsByName(b[a].name)[0].value=b[a].value:document.getElementById(b[a].id).value=b[a].value:"radio"==b[a].type||"checkbox"==b[a].type?document.getElementsByTagName("input")[b[a].index].checked=b[a].checked:"option"==b[a].type&&document.getElementsByTagName("option")[b[a].index].value==b[a].value&&(document.getElementsByTagName("option")[b[a].index].selected=b[a].selected);alert("auto_login works!")})();

f:id:zariganitosh:20140723100104p:image:w450


見事!元どおりに入力された!

安全の問題

  • 以上が、ブックマークレットを利用して自動入力する基本のコードである。
  • AppleScriptで入力情報を収集して、上記のようなJavaScriptコードを自動生成するようにすれば、iPhoneiPadでも自動入力が可能になる。
    • Safariのブックマークは、iCloud経由で勝手に同期されるので便利。
  • Safariが自動入力してくれないページでも、このブックマークレットなら自動入力してくれる。(可能性大)
  • さらに、プルダウンリストやチェックボックス・ラジオボタンの状態も自動入力できるのだ。

しかし、このブックマークレットをこのまま使うのは危険である!

  • 見てのとおり、コードの中にパスワードなどの重要な情報がそのまま保存されている。
  • もしも、このブックマークレットが何らかの原因で外部に漏洩すると、不正ログインが簡単に実行できてしまう。
  • このページはスマート体組成計のページなので大した実害はないが、(体重や脂肪の計測値が見られるだけ)
  • 仮に金融機関のページのログイン情報だと、痛い目に遭う可能性大。

重要な情報は暗号化しておかなければならないのだ!

  • 暗号化を実現するコマンドとJavaScriptライブラリを探してみた。

opensslコマンドの環境

  • OSX 10.9.4に標準インストールされているopensslコマンドのバージョンは、0.9.8y。
$ openssl version
OpenSSL 0.9.8y 5 Feb 2013
  • 巷を騒がせたopensslのバグ"Heartbleed"は、openssl 1.0.1〜1.0.1fに含まれる。
  • よって、OSX標準インストールされるopnessl 0.9.8yには、バグは存在しない。
  • 仮に openssl 1.0.1〜1.0.1fを利用している場合は、1.0.1gへアップデートする。

opensslコマンドで暗号化する

  • 例:文字列"hello world!!"を"1234"というパスワードで暗号化するしてみる。
    • 暗号化によって、見えない文字コードに変換されてしまうとターミナル出力で確認できない。
    • -a = -base64オプション。ここではbase64変換で確実に見える文字にして出力してみた。
$ echo 'hello world!!' | openssl enc -e -a -salt -aes-256-cbc -pass pass:1234
U2FsdGVkX1/ViT/1pTYv4dZsujl2feiy8Wuq72NlWCI=

enc共通鍵による暗号化
-eエンコードする(暗号化する)
-dデコードする(復号化する)
-a(-base64)base64エンコード・デコードを有効にする
-salt鍵生成ルーチンでソルトを利用する。特に理由(0.9.5以前との互換性を維持する等)がない限り、-saltは必ず指定しておくべき。
-aes-256-cbc特に理由(暗号化・復号化の処理スピードの問題など)がない限り、鍵長が最も長い-aes-256-cbcを選択しておいた方が良さそう。

  • 今度は、暗号化された”U2FsdGVkX1/ViT/1pTYv4dZsujl2feiy8Wuq72NlWCI=”を復元してみる。
$ echo U2FsdGVkX1/ViT/1pTYv4dZsujl2feiy8Wuq72NlWCI= | openssl enc -d -a -salt -aes-256-cbc -pass pass:1234
hello world!!

元の文字列"hello world!!"が復元できた!

JavaScriptで復号化する

暗号文をブックマークレットに含める場合、それを復号するためのJavaScriptが必要になる。当初、二つのAES JavaScriptライブラリを見つけた。

JavaScript Implementation of AES Advanced Encryption Standard in Counter Mode | Movable Type Scripts
  • このライブラリは、AES-CTR(CounTeR:カウンタ)モードの暗号化・復号化を行う。

  • サンプル:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>aes-256-ctrサンプル</title>
</head>

<body>
<p>原文 : hello world!!</p>
<p id="encrypted_text">0</p>
<p id="decrypted_text">0</p>
</body>


<script src="http://www.movable-type.co.uk/scripts/js/crypto/aes.js">/* AES JavaScript implementation */</script>
<script src="http://www.movable-type.co.uk/scripts/js/crypto/aes-ctr.js">/* AES Counter Mode implementation */</script>

<script type="text/javascript">
    var encrypted = Aes.Ctr.encrypt("hello world!!", "1234", 256);
    document.getElementById('encrypted_text').innerText = "暗号化: " + encrypted;
    
    var decrypted = Aes.Ctr.decrypt(encrypted, "1234", 256);
    document.getElementById('decrypted_text').innerText = "復号化: " + decrypted;
</script>
</html>

  • 上記コードをファイル名"aes_ctr.html"で保存して、開いてみると...
原文 : hello world!!

暗号化: xwHwoweU3FMNRuMVxVY1bVG3qJNP

復号化: hello world!!
opensslコマンドとの互換性
  • OSX 10.9.4に標準インストールされるOpenSSL 0.9.8yは、aes-ctrに対応していない。
  • 一方、HomebrewでインストールしたOpenSSL 1.0.1hでは、aes-ctrに対応している。
  • しかし、同じaes-ctrでありながら、このJavaScriptライブラリとopensslコマンド相互の暗号化・復号化ができない。
    • 例:opensslコマンドで暗号化したものを、このJavaScriptライブラリで複合化できなかった...。(なぜだろう?Saltの扱いの違い?)
  • たとえ互換性がなくても、このJavaScriptライブラリで暗号化すれば問題なく復号化できるので、躊躇する必要はない。
crypto-js - JavaScript implementations of standard and secure cryptographic algorithms - Google Project Hosting
  • このライブラリは、AES-256-CBC(Cipher Block Chaining:暗号ブロック連鎖)モードの暗号化・復号化を行う。
    • 読み込むライブラリ(ファイル)によって、AES-256-CBC以外にも様々な暗号化に対応しているようだ。
  • サンプル:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>aes-256-cbcサンプル</title>
</head>

<body>
<p>原文      : hello world!!</p>
<p id="encrypted_text">0</p>
<p id="decrypted_text_bin">0</p>
<p id="decrypted_text_utf8">0</p>
</body>

<script src="http://crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js"></script>
<script type="text/javascript">
    var encrypted = CryptoJS.AES.encrypt("hello world!!", "1234");
    document.getElementById('encrypted_text').innerText = "暗号化     : " + encrypted;
    
    var decrypted_bin = CryptoJS.AES.decrypt(encrypted, "1234");
    document.getElementById('decrypted_text_bin').innerText = "復号 文字コード: " + decrypted_bin;
    
    var decrypted_utf8 = decrypted_bin.toString(CryptoJS.enc.Utf8);
    document.getElementById('decrypted_text_utf8').innerText = "復号 UTF8 : " + decrypted_utf8;
</script>
</html>

  • 上記コードをファイル名"aes_cbc.html"で保存して、開いてみると...
原文      : hello world!!

暗号化     : U2FsdGVkX1/jmFp4MNDcqnb8zmD/3GB+xSEWuEs+qnI=

復号 文字コード: 68656c6c6f20776f726c642121

復号 UTF8 : hello world!!
opensslコマンドとの互換性
  • OSX 10.9.4に標準インストールされるOpenSSL 0.9.8yと、完全な互換性がある。
  • 試しに、上記で暗号化したものをopensslコマンドで復号化してみると...
$ echo U2FsdGVkX1/jmFp4MNDcqnb8zmD/3GB+xSEWuEs+qnI= | openssl enc -d -a -salt -aes-256-cbc -pass pass:1234
hello world!!

  • その逆も然り...
$ echo 'hello world!!' | openssl enc -e -a -salt -aes-256-cbc -pass pass:1234
U2FsdGVkX1/ZwwQ3vwuYHqYvPDhfAKria033cuKWHEs=
原文      : hello world!!

暗号化     : U2FsdGVkX1/ZwwQ3vwuYHqYvPDhfAKria033cuKWHEs=

復号 文字コード: 68656c6c6f20776f726c6421210a

復号 UTF8 : hello world!!

どちらも、ちゃんと復号化できた!

ログイン情報を暗号化する

  • 以上の暗号化ツールを活用して、ログイン情報を暗号化してみる。
    • opensslコマンドでログイン情報を暗号化してブックマークレットコードに組み込み、
    • crypto-jsJavaScriptライブラリでブックマークレット実行時に復号するのだ。

  • 余分な改行を削除したログイン情報のテキスト。
'{https___healthcare_mb_softbank_jp_pc_assets_setup_web_explanation_input_php:[{checked:false, id:"telno3", value:"00012345678", type:"text", name:"", index:"0"}, {checked:false, id:"passwd", value:"1234", type:"password", name:"passwd", index:"1"}, {index:"3", value:"4", type:"option", name:"undefined", selected:true}]}'

  • 上記テキストをaes-256-cbcで暗号化してみる。
    • パスワード=MSTER_PASSで暗号化して、base64エンコードしてみた。
$ echo -n '{https___healthcare_mb_softbank_jp_pc_assets_setup_web_explanation_input_php:[{checked:false, id:"telno3", value:"00012345678", type:"text", name:"", index:"0"}, {checked:false, id:"passwd", value:"1234", type:"password", name:"passwd", index:"1"}, {index:"3", value:"4", type:"option", name:"undefined", selected:true}]}' | openssl enc -e -salt -aes-256-cbc -pass pass:MSTER_PASS | base64
U2FsdGVkX1/uT2sjZl3uT43ciqzmXYZpB8mwYxMpyGk0EN/Gz0hzixJ4+Js2mU68qOWohJmpPGgr5g1+eaxL8aeLh+ooMgxXYLZozYUhWaJ99+0+tKV9GDarpbOMrfA6zE8hvCy62XChqZZINsPOA7G3KuFs54+BKNXFqKbsyA2f0MciDxQcmZgeTC8ci+rS1WZHyTMK5EPh6GhPK/QpoejG1JdXpcGdV94pN7R8flpT4iyMdGdbCfSeL7xjnmZs3BMvc5S50VZEtIdsD9BCeixQFf7GCZvuTJH4zxQHoPZMLc1T85ReBliiya9PvHcD6VfxTk4iuvodqIAC3J/pF5IP5fEhR/24XOZ/5gcEa92u8HulCp0LgfAeUxrgABo0a+ELvpysYBKQmwuTNU9ssFh9QA841RXvLh/JUnQAE8YTksS27PF50nhZap6C1OC5rOIRvIdrTgSgImjQ6TxhiA==

  • 暗号化されたログイン情報を利用したブックマークレットに修正してみた。
    • JavaScriptライブラリをロードする時間を考慮して、setTimeoutで0.5秒待機してから実行している。
    • ネットワーク環境やブラウザの性能によって、より多くの待機時間が必要になるかもしれない。
(function(){

//JavaScriptライブラリをロード
var e=document.createElement('script');
e.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';
document.body.appendChild(e);

setTimeout(function(){
//暗号化ログイン情報を復号する
var passwd_raw=prompt('auto_login Password','');
var json_text_cipher='U2FsdGVkX1/uT2sjZl3uT43ciqzmXYZpB8mwYxMpyGk0EN/Gz0hzixJ4+Js2mU68qOWohJmpPGgr5g1+eaxL8aeLh+ooMgxXYLZozYUhWaJ99+0+tKV9GDarpbOMrfA6zE8hvCy62XChqZZINsPOA7G3KuFs54+BKNXFqKbsyA2f0MciDxQcmZgeTC8ci+rS1WZHyTMK5EPh6GhPK/QpoejG1JdXpcGdV94pN7R8flpT4iyMdGdbCfSeL7xjnmZs3BMvc5S50VZEtIdsD9BCeixQFf7GCZvuTJH4zxQHoPZMLc1T85ReBliiya9PvHcD6VfxTk4iuvodqIAC3J/pF5IP5fEhR/24XOZ/5gcEa92u8HulCp0LgfAeUxrgABo0a+ELvpysYBKQmwuTNU9ssFh9QA841RXvLh/JUnQAE8YTksS27PF50nhZap6C1OC5rOIRvIdrTgSgImjQ6TxhiA==';
var json_text=CryptoJS.AES.decrypt(json_text_cipher, passwd_raw).toString(CryptoJS.enc.Utf8);
var json=eval('('+json_text+')');

//URLに対応したログイン情報を取り出す
var url_key=document.location.protocol.replace(/[^A-Za-z]/g,'_')+'__'+document.location.hostname.replace(/[^A-Za-z]/g,'_')+document.location.pathname.replace(/\W/g,'_');
var j=json[url_key];

//自動入力する
for(var i=0;i<j.length;i++){
  if(j[i].type=='text'||j[i].type=='password'){
    if(document.getElementsByName(j[i].name).length > 0){
      document.getElementsByName(j[i].name)[0].value=j[i].value
    }else{
      document.getElementById(j[i].id).value=j[i].value
    }
  }else if(j[i].type=='radio'||j[i].type=='checkbox'){
    document.getElementsByTagName('input')[j[i].index].checked=j[i].checked
  }else if(j[i].type=='option'){
    if(document.getElementsByTagName('option')[j[i].index].value==j[i].value){
      document.getElementsByTagName('option')[j[i].index].selected=j[i].selected
    }
  }
}
alert('auto_login works!');
}, 500);//setTimeout


})()
  • ブックマークレット中に含まれるのは暗号化されたログイン情報である。
  • ログイン情報を復号するためのマスターパスワードの入力が、毎回必要になるが、
  • たとえ漏洩したとしても、パスワードを知らない限り、不正利用はできないのだ。

これでログイン情報は暗号化され、安全になった!

マスターパスワードの照合

  • これでブックマークレットは安全になったが、一つ問題が発生した。
  • マスターパスワードが正しければ正常に自動入力されるのだけど、
  • マスターパスワードが間違っていると、何のリアクションもない。
  • 仮にパスワードが違っていた場合は「パスワードが違います」くらいの出力をしたいものだ。
  • 照合するためには、正しいマスターパスワードをどこかに保存しておく必要がある。
  • ところが、どこかに保存すると、それが漏洩したときの危険性が気になる。
  • できることなら、マスターパスワードはどこにも保存せずに、照合するのがベスト。
  • では、正しいマスターパスワードを知らずして、正誤判定する方法なんてあるのか?

ハッシュ値を使うと、それができる!

ハッシュ値の特性

  • 原文からハッシュ値を素早く求めることはできるが、
    • ハッシュ値から原文を推測することは相当困難。(現状は不可能)
  • 原文が同じ内容なら、必ず同一のハッシュ値になる。
    • 1文字でも違っていると、その値は大きく変化する。

  • 上記の特性を利用するとこで、パスワードの照合を安全に行うことができる。
    1. パスワードは原文を保存せず、そのハッシュ値を保存しておく。
    2. ユーザーがパスワード入力したら、そのハッシュ値を計算する。
    3. 上記1.と2.のハッシュ値の一致を確認すれば、入力されたパスワードが正しいと判定できる。

  • 仮にハッシュ値が漏洩しても、パスワード原文は推測できないので安全なのだ。
    • 一方、パスワード原文を保存していた場合は、それが漏洩した時に大問題になる。

opensslコマンドでsha256ハッシュ値を取得する

  • sha256を利用してマスターパスワードの照合を行うことにした。
    • sha1からsha256への移行が推奨されているため、sha256を利用した。
$ echo -n 1234 | openssl dgst -sha256
03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4

JavaScriptでsha256ハッシュ値を取得する

sha256ハッシュを計算するJavaScriptライブラリでも試してみた。

‎user1.matsumoto.ne.jp/~goma/js/sha1.js
  • サンプルコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>SHA-256サンプル</title>
</head>

<body>
<p>原文      : 1234</p>
<p id="encrypted_text">0</p>
</body>

<script src="http://user1.matsumoto.ne.jp/~goma/js/sha256.js"></script>
<script type="text/javascript">
    var encrypted = sha256.hex("1234");
    document.getElementById('encrypted_text').innerText = "sha256     : " + encrypted;
</script>
</html>
  • 上記サンプルコードをSafariで開いてみると...
原文      : 1234

sha256     : 03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4
crypto-js - JavaScript implementations of standard and secure cryptographic algorithms - Google Project Hosting
  • サンプルコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>sha256サンプル</title>
</head>

<body>
<p>原文      : 1234</p>
<p id="encrypted_text">0</p>
</body>

<script src="http://crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/sha256.js"></script>
<script type="text/javascript">
    var encrypted = CryptoJS.SHA256("1234");
    document.getElementById('encrypted_text').innerText = "sha256     : " + encrypted;
</script>
</html>
  • 上記サンプルコードをSafariで開いてみると...
原文      : 1234

sha256     : 03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4

opensslコマンド・Javascriptライブラリ、いずれのハッシュ値も同一の値だ!

sha256でマスターパスワードを照合

  • 以上のハッシュ関数を活用して、マスターパスワードを照合してみる。
    • opensslコマンドとcrypto-jsJavaScriptライブラリを使ってみた。

  • まずはMASTER_PASSのsha256ハッシュ値を求めた。
$ echo -n MASTER_PASS | openssl dgst -sha256
3b70f81b4a16b6546ea8114e43be495ce154c3a1e92056a59d7af3db6a9914fd

  • 上記ハッシュ値を利用して、パスワードを照合するコードを追加してみた。
(function(){

//JavaScriptライブラリをロード
(function(e){
  e=document.createElement('script');
  e.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';
  document.body.appendChild(e);
  e=document.createElement('script');
  e.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/sha256.js';
  document.body.appendChild(e);
})()

setTimeout(function(){
//マスターパスワードを照合する
var passwd_sha256='3b70f81b4a16b6546ea8114e43be495ce154c3a1e92056a59d7af3db6a9914fd';
var passwd_raw=prompt('auto_login Password','');
if(CryptoJS.SHA256(passwd_raw) != passwd_sha256){
  if(passwd_raw != ''){alert('Password is NG.');}return;
}

//暗号化ログイン情報を復号する
var json_text_cipher='U2FsdGVkX1/uT2sjZl3uT43ciqzmXYZpB8mwYxMpyGk0EN/Gz0hzixJ4+Js2mU68qOWohJmpPGgr5g1+eaxL8aeLh+ooMgxXYLZozYUhWaJ99+0+tKV9GDarpbOMrfA6zE8hvCy62XChqZZINsPOA7G3KuFs54+BKNXFqKbsyA2f0MciDxQcmZgeTC8ci+rS1WZHyTMK5EPh6GhPK/QpoejG1JdXpcGdV94pN7R8flpT4iyMdGdbCfSeL7xjnmZs3BMvc5S50VZEtIdsD9BCeixQFf7GCZvuTJH4zxQHoPZMLc1T85ReBliiya9PvHcD6VfxTk4iuvodqIAC3J/pF5IP5fEhR/24XOZ/5gcEa92u8HulCp0LgfAeUxrgABo0a+ELvpysYBKQmwuTNU9ssFh9QA841RXvLh/JUnQAE8YTksS27PF50nhZap6C1OC5rOIRvIdrTgSgImjQ6TxhiA==';
var json_text=CryptoJS.AES.decrypt(json_text_cipher, passwd_raw).toString(CryptoJS.enc.Utf8);
var json=eval('('+json_text+')');

//URLに対応したログイン情報を取り出す
var url_key=document.location.protocol.replace(/[^A-Za-z]/g,'_')+'__'+document.location.hostname.replace(/[^A-Za-z]/g,'_')+document.location.pathname.replace(/\W/g,'_');
var j=json[url_key];
if(j==null){
  alert('No auto_login info.');return;
}

//自動入力する
for(var i=0;i<j.length;i++){
  if(j[i].type=='text'||j[i].type=='password'){
    if(document.getElementsByName(j[i].name).length > 0){
      document.getElementsByName(j[i].name)[0].value=j[i].value
    }else{
      document.getElementById(j[i].id).value=j[i].value
    }
  }else if(j[i].type=='radio'||j[i].type=='checkbox'){
    document.getElementsByTagName('input')[j[i].index].checked=j[i].checked
  }else if(j[i].type=='option'){
    if(document.getElementsByTagName('option')[j[i].index].value==j[i].value){
      document.getElementsByTagName('option')[j[i].index].selected=j[i].selected
    }
  }
}
alert('auto_login works!');
}, 500);//setTimeout


})()

これでマスターパスワードの照合もできるようになった!

  • 状況に応じて、以下のメッセージを出力するのだ。
    • マスターパスワードが違っていたら、'Password is NG.'
    • ログイン情報が見つからなかったら、'No auto_login info.'
    • 正常に自動入力できた場合は、'auto_login works!'

auto_loginプロジェクト

以上の仕組みを利用して、auto_loginプロジェクトはログイン情報を保全している。

      • 果たして、ちゃんと安全を確保できているのだろうか?

関連ページ


onloadすればsetTimeoutが不要

  • JavaScriptライブラリをロードする時間を考慮して、setTimeoutで大雑把に0.5秒待機する、という運を天に任せる的なコードは、常々どうにかしたいと思っていた。
  • 0.5秒も待つ必要ないかもしれないし、あるいはもっと待つ必要があるかもしれない。
  • 理想は、JavaScriptライブラリのロードが完了したタイミングで、本体のブックマークレットを実行するべき。

ロードが完了したタイミング=onloadイベントハンドラを使えばいいのだ!

  • scriptタグを追加する時に、onload属性を追加して、ブックマークレット本体の関数を呼び出すように変更してみた。
(function(f,e){
//JavaScriptライブラリをロード
e=document.createElement('script');
e.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js';
document.body.appendChild(e);
e=document.createElement('script');
e.src='//crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/sha256.js';
e.onload=function(){f()};
document.body.appendChild(e);

})(function(){
//マスターパスワードを照合する
var passwd_sha256='3b70f81b4a16b6546ea8114e43be495ce154c3a1e92056a59d7af3db6a9914fd';
var passwd_raw=prompt('auto_login Password','');
if(CryptoJS.SHA256(passwd_raw) != passwd_sha256){
  if(passwd_raw != ''){alert('Password is NG.');}return;
}

//暗号化ログイン情報を復号する
var json_text_cipher='U2FsdGVkX1/uT2sjZl3uT43ciqzmXYZpB8mwYxMpyGk0EN/Gz0hzixJ4+Js2mU68qOWohJmpPGgr5g1+eaxL8aeLh+ooMgxXYLZozYUhWaJ99+0+tKV9GDarpbOMrfA6zE8hvCy62XChqZZINsPOA7G3KuFs54+BKNXFqKbsyA2f0MciDxQcmZgeTC8ci+rS1WZHyTMK5EPh6GhPK/QpoejG1JdXpcGdV94pN7R8flpT4iyMdGdbCfSeL7xjnmZs3BMvc5S50VZEtIdsD9BCeixQFf7GCZvuTJH4zxQHoPZMLc1T85ReBliiya9PvHcD6VfxTk4iuvodqIAC3J/pF5IP5fEhR/24XOZ/5gcEa92u8HulCp0LgfAeUxrgABo0a+ELvpysYBKQmwuTNU9ssFh9QA841RXvLh/JUnQAE8YTksS27PF50nhZap6C1OC5rOIRvIdrTgSgImjQ6TxhiA==';
var json_text=CryptoJS.AES.decrypt(json_text_cipher, passwd_raw).toString(CryptoJS.enc.Utf8);
var json=eval('('+json_text+')');

//URLに対応したログイン情報を取り出す
var url_key=document.location.protocol.replace(/[^A-Za-z]/g,'_')+'__'+document.location.hostname.replace(/[^A-Za-z]/g,'_')+document.location.pathname.replace(/\W/g,'_');
var j=json[url_key];
if(typeof j==='undefined'){
  alert('No auto_login info.');return;
}

//自動入力する
for(var i=0;i<j.length;i++){
  if(j[i].type=='text'||j[i].type=='password'){
    if(document.getElementsByName(j[i].name).length > 0){
      document.getElementsByName(j[i].name)[0].value=j[i].value
    }else{
      document.getElementById(j[i].id).value=j[i].value
    }
  }else if(j[i].type=='radio'||j[i].type=='checkbox'){
    document.getElementsByTagName('input')[j[i].index].checked=j[i].checked
  }else if(j[i].type=='option'){
    if(document.getElementsByTagName('option')[j[i].index].value==j[i].value){
      document.getElementsByTagName('option')[j[i].index].selected=j[i].selected
    }
  }
}
alert('auto_login works!');
})

2014-07-17

ブックマークレットの書き方の段階的な発展の仕方

ブックマークレットとは、JavaScriptコードが保存されたブックマークのことである。クリックすると、そこに保存されているJavaScriptコードが実行される。

  • 通常のブックマークのアドレスには、http:で始まるURLが保存されている。
  • ブックマークレットのアドレスには、javascript:で始まるJavaScriptコードが保存されている。

はじめの一歩

WebブラウザのURLフィールドに以下のように入力して、returnキーを押してみると...

javascript:alert("hello world!!")

f:id:zariganitosh:20140715164844p:image:w450


アラートダイアログが表示された!

f:id:zariganitosh:20140715165106p:image:w428


URLフィールドのアイコンをブックマークバーにドラッグ&ドロップすると、そのJavaScriptはブックマークレットとして保存される。

f:id:zariganitosh:20140715172852p:image:w450


あるいは、aタグのリンク先にJavaScriptを設定しておけば、そのリンクをドラッグ&ドロップしてもブックマークレットとして保存される。

<a href="javascript:alert('hello world!!')">helloアラート</a>

helloアラート ←このリンクをドラッグ&ドロップするのだ。但し...

      • 残念ながら、はてなダイアリーではaタグのリンク先にJavaScriptを設定することが許可されていない。
      • 公開する時に以下のタグに変換されてしまう。よって、上記リンクはブックマークレットにならない...。
<a target="_blank" href="">helloアラート</a>

とにかく、ブラウザのブックマークのアドレス項目に、javascript:で始まるJavaScriptコードを書いておけば、そのコードは実行されるのだ。

変数のスコープ問題

  • ブックマークレットは便利なんだけど、今までの書き方では問題が発生することがある。
  • 例えば、クリックするごとにカウンターを+1する以下のようなページを想像してみる。
<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8">
<title>JavaScriptサンプル</title>
<script type="text/javascript">
var counter = 0;
</script>
</head>

<body>
<p id="counter_text">0</p>
<a href="javascript:counter++; document.getElementById('counter_text').innerText = counter;">カウントアップ</a> 
</body>

</html>
  • 上記ページ(count_up.html)を表示すると、カウントアップをクリックする度に、1、2...と順にクリックした回数が表示される。

f:id:zariganitosh:20140716112126p:image:w450


  • では、このページで以下のようなブックマークレットを実行すると、どうなるか?
javascript:var counter=100;alert(counter);

  • 上記ブックマークレット実行後にカウントアップをクリックすると、いきなり101になってしまう...。

f:id:zariganitosh:20140716112616p:image:w450


  • この原因は、count_up.htmlページとブックマークレットでcounter変数を共用する状態になっている所にある。
    • この状況で、counterはグローバル変数として扱われている。
    • グローバル変数は、そのページで実行されるJavaScriptコードなら、どこからでも参照できる。
    • ブックマークレットも、表示されているページに属するJavaScriptコードとして実行される。

グローバル変数とローカル変数

  • JavaScriptの変数には、たった二つのスコープしかない。
    • グローバル変数=そのページのJavaScriptコードのどこからでも参照可能
    • ローカル変数=変数宣言された関数の中でだけ参照可能
  • ローカル変数の作り方
    • 関数の中でvar宣言した変数
    • 関数の仮引数
  • グローバル変数の作り方
    • 関数の外でvar宣言した変数
    • varなし宣言した変数

例:

var out_total;
function sum_alert(a, b){
  var total = a + b;
  global_total = total;
  out_total = total;
  alert("合計: "+total);
}
sum_alert(1, 2);
alert("global合計"+global_total);
alert("out合計"+out_total);
alert(total); //ReferenceError: Can't find variable: total (Line: NaN)
alert(a); //ReferenceError: Can't find variable: a (Line: NaN)
alert(b); //ReferenceError: Can't find variable: b (Line: NaN)
ローカル変数グローバル変数
関数の中でvar宣言した変数=total関数の外でvar宣言した変数=out_total
関数の仮引数=a, bvarなし宣言した変数 global_total

変数を汚染しないブックマークレット

以上のことから...

  • ブックマークレットで使う変数は、すべてローカル変数にすべきなのだ。
  • つまり、すべてのブックマークレットは関数定義の中に書く必要がある。
  • 先ほどのカウンターアラートのブックマークレットも、
javascript:var counter=100;alert(counter);
  • 関数の中に書いておけば変数を汚染しないはずである。
javascript:function wrap(){var counter=100;alert(counter);} wrap();
  • wrap()という名の関数で包み、直後にwrap()関数を実行している。
  • これでカウントアップするカウンター値に影響を与えなくなった!

ところが、まだ完璧ではない...。

  • もしも元ページのJavaScriptコードにwrap関数が定義されていたら、上記ブックマークレット実行後は動作がおかしくなってしまう。
  • それを避けるためには絶対に重複しない関数名にしておく必要があるのだが、関数名がある限り、絶対に重複しないとは言い切れなくなる。
    • 但し、zariganitosh_bookmarklet_wrapのような関数名にしておけば、実用上問題ないレベルで重複しないはずなのだが。
    • しかし、このような長い関数名は無駄だし、固有名詞を入れたくないし、コードも美しくない...。
  • 関数名があるから重複するわけで、ならば関数名を付けなければいい。そう、無名関数を使うのだ。
javascript:(function(){var counter=100;alert(counter);})()
  • 上記のようにwrapを取り除いて、(関数定義全体)を括弧で囲い、その直後にも()を付ける。
  • すると、その関数は、定義された直後に、実行されるのだ。

イディオムとしては、以下のような書式になる。

javascript:(  function(){...コード...}  )()

( function(){...コード...} )()で何が起こっているのか?

  • 括弧の連打に惑わされて、何が起こっているのか見失いそうになるが、じっくり眺めてみる。
  • JavaScriptには、関数定義の方法が複数用意されている。
  • その一つは、先ほどのfunctionで始まるwrap関数の定義である。
javascript:function wrap(){var counter=100;alert(counter);}

  • もう一つの方法は、無名関数を変数に代入する方法もある。
javascript:wrap = function(){var counter=100;alert(counter);};

  • コードが評価されるタイミングなど若干の違いはあるが、最終的にどちらもグローバルなwrap変数に関数が代入されるという意味では同じ。
  • wrap変数の内容を表示してみると、同じであることが理解できる。
関数定義のブロック
  • コード行頭(;で区切られた先頭)がfunctionで始まると、関数定義のブロックと見なされ、}の後に;は不要。
javascript:function wrap(){var counter=100;alert(counter);} alert(wrap)

f:id:zariganitosh:20140716150553p:image:w428


  • また、関数定義のブロックは、実行に先立ってコード全体が解釈される時に評価される。
  • その証拠に、alert(wrap); を先に書いても、wrapには関数が代入されている。
javascript:alert(wrap); function wrap(){var counter=100;alert(counter);}
関数定義を返す関数
  • 一方、式の途中にfunctionがあると、式の一部と見なされ、}の後に;が必要。
javascript:wrap = function(){var counter=100;alert(counter);}; alert(wrap)

f:id:zariganitosh:20140716150551p:image:w428


  • 式の途中のfunctionは、コード実行中に関数定義を返す関数として実行される。
  • function関数が関数オブジェクトを生成して返し、それを変数wrapに代入しているのである。
javascript:alert(wrap); wrap = function(){var counter=100;alert(counter);};

  • どちらも変数wrapにも、関数定義のコードそのものが代入されている。
    • 但し、関数定義のブロックとしてのfunctionは、事前に評価されるので、コード実行中には何も返さない。
    • 一方、関数定義を返す関数としてのfunctionは、コード実行中に関数オブジェクトを返す。

  • では、関数定義のブロックなのか、関数定義を返す関数なのか、その違いはどこで決まるのだろう?
  • それには、シンプルなルールがある。
    • 行頭がfunctionで始まれば、関数定義のブロック。
    • 行頭がfunctiion以外で始まり、途中にfunctionがあると、関数定義を返す関数。

  • ここでやっと( function(){...コード...} )()の意味を考えてみる。
  • 先頭が(で始まるので、このfunctionは関数定義を返す関数である。
  • よって、( function(){...コード...} )の部分は関数オブジェクトと評価される。
  • 関数オブジェクトに()を付けると、それは関数呼び出しとして実行される。

つまり、無名関数の定義と実行をすることになるのだ!


  • ちなみに、行頭がfunctiion以外で始まればOKなので、()で囲う以外にもいくつかの方法がある。
javascript:+function(){var counter=100;alert(counter);}()
javascript:-function(){var counter=100;alert(counter);}()
javascript:void function(){var counter=100;alert(counter);}()
  • 矛盾のない式と評価されるなら、どのような書き方でもOK。
  • でも、一般的によく使われるのは ( function(){...コード...} )() である。

特に理由がない限り ( function(){...コード...} )() を使っておいた方が良さそう。

長い長いブックマークレット問題

ここまで、独立したブックマークレット実行環境を手に入れられた。

  • 40370文字のブックマークレットは、SafariのブックマークとしてはiCloud経由で同期できないのだけども...
  • OSXのSafariのブックマークにはちゃんと登録できる。実行すると、正常に動作して、ログイン情報を自動入力してくれた。
  • 同様にiOSのSafariのブックマークの編集ページでJavaScriptコードをペーストすると、ちゃんと登録できた。動作も正常。
    • 但し、メモ.appから40370文字のコードをコピーして、iOSのSafariのブックマークにペーストすると、ペーストが完了するまで5分もかかった。
    • さらに、ペースト完了後にSafariのDoneボタンを押して、ブックマークへの保存が完了するまで、やはり5分もかかってしまった。
    • 合わせて10分も待つ必要がある。その間、iPhoneの画面の輝度が低下したら、すかさずタッチしてスリープしないように維持しなければならない。

面倒くさ過ぎる!

  • やはり、ブックマークは勝手に自動で同期して欲しい。
  • では何文字までのブックマークなら自動同期するのか?

調べてみると、3913文字(バイト)までだった。

  • 3913文字(バイト)を1文字でもオーバーすると、自分のiCloud環境ではSafariのブックマークの同期が止まる...。
    • あり得ない不便さである。
    • 以前は3万文字程度のブックマークレットもちゃんと同期していたと思っていたのだが、いつから3913文字になってしまったのだろう?
  • しかし、嘆いていてもしょうがない。この現実に対応するしかない...。

外部ファイルをロードするブックマークレット

  • 最初はログイン情報を圧縮することを考えたが、40370文字のブックマークレットを3913文字に収めるのはどう考えても無理。
    • そもそも、ログイン情報を全部削除しても10000文字程度のサイズであった。

  • 残る手段は、本体のJavaScriptコードを外部ファイルに保存しておくしかない。
  • ブックマークレットに保存するコードは、その外部ファイルをロードするだけ。

  • 例えば、DropboxのPublicフォルダにbookmarklet.jsというファイルを作っておく。
    • 外部ファイルはブックマークレットではないので、javascript:は不要になる。
    • Publicフォルダが存在しない場合は、以下のページから有効にできる。
    • Public フォルダの用途は。 - Dropbox
(function(){var counter=100;alert(counter);})()

  • すかさず、bookmarklet.jsを二本指で選択して、公開リンクをコピーしておく。
    • XXXXXXXの部分は、自分のDropboxのユーザー番号に置き換えるのだ。
https://dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js

  • 上記bookmarklet.jsをロードするブックマークレットは、以下のように書ける。
javascript:(function(){var s=document.createElement('script');s.src='//dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js';document.body.appendChild(s);})()
  • さらに、仮引数を利用することで、より短く、よりシンプルに洗練させてみる。(あまり変わらない?)
javascript:(function(d,s){s=d.createElement('script');s.src='//dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js';d.body.appendChild(s);})(document)

  • やっていることは、Webページのbodyタグの中に、以下のscriptタグを追加しているだけ。
<script src='//dl.dropboxusercontent.com/u/XXXXXXX/bookmarklet.js'></script>
  • 上記scriptタグが追加されると、srcのURLにあるファイルがロードされ、実行されるのだ!
    • URLのhttpあるいはhttpsを省略しておくと、ブラウザ側で良きに計らい使い分けてくれる。
    • ロードするブックマークレット、外部ファイルともにfunctionでラッピングしているので、既存のJavaScriptには影響を与えないはず。(と思っている)
  • ブックマークレット自体は、たった149文字のコードである。iCloud経由で素早く同期されるはず。

これで、長い長いブックマークレット問題も解決!

2013-06-11

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

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:    }


なるほど、なるほど。ようやくコードの流れがすべて理解できた。

これを雛形に様々なページを集計できるのだ!(モロ屋さんに感謝!)

参考ページ

以下のページが大変参考になりました。感謝です!