無駄と文化

実用的ブログ

JavaScript実行済みのDOMをHTMLファイルとして保存するブックマークレット

この記事の公開後に noromanba 様から「DOMのテキスト変換はXMLSerializerを使えば一撃でいけるよ!」的なご指摘を頂いたので追記しました。
本当にありがとうございます。


スクレイピングネタです。

クローリング+スクレイピングするにあたってパスワード認証の掛かったページから情報を取得するのはなかなかに厄介な課題ですよね。
スクレイピングのフレームワークによっては認証のための機能が備わっていたりしますが、それが無い場合には自分でHTTPのレスポンスをフックしてCookie食わせたりなんだりともう大変。

しかも、100ページ超のページを対象にスクレイピングしたいならばいざ知らず、5, 6ページとかそこいらのページから情報取りたいだけで、パスワード認証のための下準備をするなんて発狂しそうになりませんか?


そんなときに新しいソリューション、パスワード認証の突破はあなたの手とあなたのブラウザでやってしまいましょう。
あなたのブラウザでログインが完了してページが表示されているならば、データの全てはすでに手元にあるということです、ひとまずHTMLとして保存してしまえば後からParserに通してスクレイピングするのは簡単なはずです。

今回は、いま表示しているページをHTMLとして保存するスクリプトをブックマークレットにしてみました。


ブックマークレット "SaveAsHTML"

そんなわけで作ったブックマークレットがコレ↓です。
あなたのブックマークバーにドラッグ&ドロップしてお使いください。

SaveAsHTML

保存したいページでブックマークボタンを押すと、ダイアログボックスが出てHTMLファイルが保存できます。


なお、Firefoxの場合、SSLで保護されたページ(https: で始まるアドレスのページ)でブックマークレットを使うと『ブックマークレットとはいえ外部スクリプト実行して大丈夫?』的な警告が出てブロックされることがあります。
こればっかりは仕方ないので、Chrome なり Opera なりを使ってください。


「ページのソースを表示」じゃダメなの?

多くの場合はそれでいいんですが、

いわゆる「ページのソースを表示」で見られるソースコードはJavaScriptが実行される前のプレーンなHTMLなんですよね。
たとえJavaScriptによってDOMが組み替えられるページでも、組み替え前の構造しか見えません。
ajaxによってロード後にコンテンツを取ってくるタイプのサイトであれば、後読みのコンテンツは全く取得できないことになります。

そこで、今回のブックマークレットでは JS 実行後の DOM をテキスト形式に変換して保存しています。


使ってみる

使ってみましょう。

対象にするのは AngularJS でフロントエンドを構築していることでお馴染みの note.mu です。
くるりの岸田さんが書かれている岸田日記Ⅱのインデックスページを保存します。


では、ポチッと。

保存できましたね。

ちゃんとコンテンツも取れてるっぽいですね。Good.


解説

スクリプトの解説もしてみます。

まずは、実物のスクリプトを、

(function() {
  // <html> を clone
  var html = document.getElementsByTagName('html')[0].cloneNode(true);

  // href や src に指定されたURLを絶対パスに変換
  var nodes = html.querySelectorAll('[href],[src]');
  for (var i=0, n=nodes.length; i<n; i++) {
    if (nodes[i].href) { nodes[i].href=nodes[i].href; }
    if (nodes[i].src) { nodes[i].src=nodes[i].src; }

    console.log('href: ' + nodes[i].href + ', src: ' + nodes[i].src);
  }

  // ソースコードをテキストで取得
  var src = html.innerHTML;
  console.log(src.slice(0, 5000));

  // 上記の src には DOCTYPE が含まれていないので別途用意
  var name     = document.doctype.name;
  var publicId = document.doctype.publicId;
  var systemID = document.doctype.systemId;
  var doctype  = '<!DOCTYPE ' + name
                 + (publicId ? ' PUBLIC "' + publicId + '"' : '')
                 + (systemID ? ' "' + systemID + '"' : '')
                 + '>';
  console.log(doctype);

  // <html> タグを再構成
  var htmlTag = '<html';
  var attrs = html.attributes;
  for (var i=0, n=attrs.length; i<n; i++) {
    var attr = attrs[i];
    htmlTag += ' ' + attr.nodeName + (attr.nodeValue ? '="' + attr.nodeValue + '"' : '');
  }
  htmlTag += '>';
  console.log(htmlTag);

  // ソースコードを Blob オブジェクトに変換してURLを取得
  var blob    = new Blob([doctype, '\n', htmlTag, '\n', src, '\n</html>']);
  var url     = window.URL || window.webkitURL;
  var blobURL = url.createObjectURL(blob);

  // <a> を新たに作成し、ダウンロード用の設定をいろいろ
  var a = document.createElement('a');
  // URI を元にダウンロード時のファイル名を決定
  a.download = decodeURI(location.pathname+location.hash).replace(/\//g,'__').replace(/#/g,'--') + '.html';
  a.href     = blobURL;

  a.click();
})();


最初に var html = document.getElementsByTagName('html')[0]<html> の node を取得しています。
その後、html.innerHTMLを参照すればページのほとんどの DOM がテキストで手に入ります。

なぜ「全ての DOM」ではなく「ほとんどの DOM」かというと、

  • <!DOCTYPE><html> の外側にある
  • innerHTML では一番外側の <html> タグの内容を含めてくれない

からです。

この二つの問題を解消するために結構コード量が増えてます。ふーむ。


1. <!DOCTYPE> を再構成

<!DOCTYPE> の情報は DocumentType オブジェクト として保持されており、JavaScript で document.doctype を参照することでアクセスできます。

特に、

  • document.doctype.name
  • document.doctype.publicId
  • document.doctype.systemId

という三つの値を組み合わせれば元の <!DOCTYPE> を再構成できそうです。

今回は、

// 上記の src には DOCTYPE が含まれていないので別途用意
var name     = document.doctype.name;
var publicId = document.doctype.publicId;
var systemID = document.doctype.systemId;
var doctype  = '<!DOCTYPE ' + name
               + (publicId ? ' PUBLIC "' + publicId + '"' : '')
               + (systemID ? ' "' + systemID + '"' : '')
               + '>';

てな感じにやってます。


2. <html> タグを再構成

innerHTML は inner な HTML の情報を返してくれるだけなので、全体を包んでいるタグについての情報を含んでいません。
最外のタグも含めて DOM をテキスト化するために、外側をもう一つタグで包んで、

var targetNode = document.getElementById('target');
var tmpNode    = document.createElement('div').appendChild(targettargetNode);
var targetSrc  = tmpNode.innerHTML;

のようにする方法もありますが、
今回は、最外が <html> タグだったために、<html> をさらに別のタグで包むことができないという制約を受けてこの方法は使えませんでした。


しかたないので、 JavaScript で <html> の属性を読み出してタグを再構成します。やれやれ。

// <html> タグを再構成
var htmlTag = '<html';
var attrs = html.attributes;
for (var i=0, n=attrs.length; i<n; i++) {
  var attr = attrs[i];
  htmlTag += ' ' + attr.nodeName + (attr.nodeValue ? '="' + attr.nodeValue + '"' : '');
}
htmlTag += '>';

こんな感じで、
まぁ普通ですね。

閉じタグは </html> で固定なのでハードコーディングしちゃいます。


3. 相対パスを絶対パスに変換

もう一つ、これはただのサービス精神なんですが、
ソース中の hrefsrc に指定されているURLを全て絶対パスに変換しています。
保存した HTML をローカルで見たときにパスが切れてたら萎えるだろというところを見越した粋な計らいですね。

var nodes = html.querySelectorAll('[href],[src]');
for (var i=0, n=nodes.length; i<n; i++) {
  if (nodes[i].href) { nodes[i].href=nodes[i].href; }
  if (nodes[i].src) { nodes[i].src=nodes[i].src; }
}

こんな感じ。

相対パス や ドメイン相対パス を 絶対パス に変換するにあたって pure JS でパスの解決するの面倒そうだなと思って躊躇したんですが、普通に element.href を参照するだけで絶対パスを返してくれる親切設計でした。

元の element.href が相対指定であれドメイン相対指定であれ絶対指定であれ、element.hrefelement.href で上書きしてあげることで絶対パスに変換できます。 (日本語的に何言ってんだこいつって感じだけど、事実なんだよなぁ...)


4. テキストをファイルとして保存

ソースコードの準備が整ったら、それをHTMLファイルとして保存します。
File API の出番ですね。

// ソースコードを Blob オブジェクトに変換してURLを取得
var blob    = new Blob([doctype, '\n', htmlTag, '\n', src, '\n</html>']);
var url     = window.URL || window.webkitURL;
var blobURL = url.createObjectURL(blob);

// <a> を新たに作成し、ダウンロード用の設定をいろいろ
var a = document.createElement('a');
// URI を元にダウンロード時のファイル名を決定
a.download = decodeURI(location.pathname+location.hash).replace(/\//g,'__').replace(/#/g,'--') + '.html';
a.href     = blobURL;

a.click();

まず new Blob()StringBlob オブジェクトに変換します。
window.URL.createObjectURL()Blob を参照するURLを取得して、<a> タグの href に設定します。

<a> タグの download 属性にファイル名を指定することで、<a> タグをクリックした際の動作が画面遷移からファイルとしてダウンロードに変更されます。

最後に、a.click() してあげればダイアログボックスが開いてソースコードを保存できるようになります。


ワンライナー化

さて、スクリプトの内容の解説はこんなもんにして、

ブックマークレットとして使うからには改行を取り除いて1行にしておかなければいけません。
それと、スクリプト全体がなるべく短い方がいいので適度に Golf しましょう。


まずは Golf から。
グローバルを汚さずに識別子var を取り除くために、変数は全体をラップしている無名関数の仮引数に押し込んでしまいます。

(function(window, document, location, doctype, nodes, html, src, htmlTag, attrs, a, i, n, t) {
  html = document.getElementsByTagName('html')[0].cloneNode(true);

  nodes = html.querySelectorAll('[href],[src]');
  for (i=0, n=nodes.length; i<n; i++) {
    t = nodes[i];
    if (t.href) { t.href = t.href; }
    if (t.src) { t.src = t.src; }

    ;;; console.log('href: ' + t.href + ', src: ' + t.src);
  }

  src = html.innerHTML;
  ;;; console.log(src.slice(0, 5000));

  doctype = document.doctype;
  doctype = '<!DOCTYPE ' + doctype.name
            + (doctype.publicId ? ' PUBLIC "' + doctype.publicId + '"' : '')
            + (doctype.systemID ? ' "' + doctype.systemID + '"' : '')
            + '>';
  ;;; console.log(doctype);

  htmlTag = '<html';
  attrs = html.attributes;
  for (i=0, n=attrs.length; i<n; i++) {
    t = attrs[i];
    htmlTag += ' ' + t.nodeName + (t.nodeValue ? '="' + t.nodeValue + '"' : '');
  }
  htmlTag += '>';
  ;;; console.log(htmlTag);

  a = document.createElement('a');
  a.download = decodeURI(location.pathname+location.hash).replace(/\//g,'__').replace(/#/g,'--') + '.html';
  a.href = (window.URL || window.webkitURL).createObjectURL(
    new Blob([doctype, '\n', htmlTag, '\n', src, '\n</html>'])
  );

  a.click();
})(window, document, location);

Golf するとか言いつつ変数名が長いままですが、これは後で /packer/ が短い変数名に置き換えてくれるので、この時点では可読性を残しています。

console.log() とかのデバッグ用の行に ;;; がついているのも後で /packer/ に削除してもらうための目印です。


というわけで、 /packer/ で圧縮します。
Shrink variables に☑を入れて...


(function(b,c,d,e,f,g,h,j,k,a,i,n,t){g=c.getElementsByTagName('html')[0].cloneNode(true);f=g.querySelectorAll('[href],[src]');for(i=0,n=f.length;i<n;i++){t=f[i];if(t.href){t.href=t.href}if(t.src){t.src=t.src}}h=g.innerHTML;e=c.doctype;e='<!DOCTYPE '+e.name+(e.publicId?' PUBLIC "'+e.publicId+'"':'')+(e.systemID?' "'+e.systemID+'"':'')+'>';j='<html';k=g.attributes;for(i=0,n=k.length;i<n;i++){t=k[i];j+=' '+t.nodeName+(t.nodeValue?'="'+t.nodeValue+'"':'')}j+='>';a=c.createElement('a');a.download=decodeURI(d.pathname+d.hash).replace(/\//g,'__').replace(/#/g,'--')+'.html';a.href=(b.URL||b.webkitURL).createObjectURL(new Blob([e,'\n',j,'\n',h,'\n</html>']));a.click()})(window,document,location);

はい。


ブックマークレット化

先ほどの1行スクリプトの先頭に javascript:を付加するだけですね。

HTML中に埋め込む場合はダブルクォート" を参照文字の&quot; に置き換えるのも忘れずに。


まとめ

このブックマークレット、前々から欲しかったんですが、かといってチャチャッと書くのがなかなかに面倒だったんですよね。
こんかいちゃんと形にしたからには大事に使っていきたいです。

JS実行後のソースを保存したくなる事、半年に一回くらいしか無いけど。


追記

この記事の公開後に noromanba 様から「DOMのテキスト変換はXMLSerializerを使えば一撃でいけるよ!」的なご指摘を頂きました。
本当にありがとうございます。

// 一撃!!
var snapshot = new XMLSerializer().serializeToString(document);

これです、顧客が本当に必要だったものは。


参考

以下を参考にしつつ書かせてもらいました。感謝!