さんまがおいしい季節だねー(´・ω・`)

node.jsとjQueryでスクレイピングするウェブアプリの作り方

JavaScript — タグ: , , — さくら @ 2011/02/01 0:51

やっぱ jQuery 便利ですよ(*´・ω・)(・ω・`*)ネー

セレクタ使って jQuery でダカダカやってると、DOM とか正規表現でネチネチやるのがバカらしくなっちゃいます。

と日頃から思ってたりしてまして、サーバサイド JavaScript がメインストリームになって、jQuery でウェブアプリをコーディングできれば超ラクできるかもと期待しています。

で、先日サーバサイドJavaScriptとjQueryでスクレイピングという記事をうpったところ、やっぱ Rhino じゃなくて node.js がえーんよ(´・ω・`)というコメントを頂きましたので、node.js と jQuery でサーバサイド JavaScript スクレイピングしてみることにしました。

今回は node.js ですので、単にスクレイピングする(コマンドラインから実行する)スクリプトだけじゃなくて、スクレイピングする簡単なウェブアプリを作ってみたいと思います。

jQuery node.js

結構長文になっちゃったので、先に今日のブログで扱ってるテーマを書いときます。

  1. jsdom パッケージを使って node.js で jQuery する方法
  2. jsdom パッケージの HTML コンテンツ中の script タグの扱いに関する問題の回避方法
  3. 文字エンコーディングを考慮した HTML コンテンツのダウンロード方法
  4. スクレイピングウェブアプリの作り方

あと完成品のソースを入れた ZIP も先に置いときますので、中身から見たいセッカチさんはこちらからどうぞ。

nodejs-linkpicker.zip

要るもの

まず node.js が必要です。それと node.js のライブラリをインスコするために npm (Node Package Manager)を使います。一緒にインスコしておいてください。

node.js のインスコ方法については node.js の公式サイトを、npm のインスコ方法については配布元 github の isaacs /npm をご覧ください。

英語読むのがメンドくせー方は node.jsとMySQLで割と普通のデータベースウェブアプリを作ってみるチュートリアルに node.js と npm のインスコ方法を書いてますのでそちらとかどうぞ。

jsdom

node.js 本体には DOM ライブラリが付いてませんのでそのままでは jQuery を使えませんが、npm パッケージの jsdom という DOM ライブラリをインスコすると jQuery も使えるようになります。

jsdom は npm install でインスコできます。

$ npm install jsdom

とりあえず準備はこれで終わりです。

jQuery を使ってみる

早速 node.js で jQuery してみます。

HTML コンテンツに div を追加するスクリプトだとこんな感じになります。

#!/usr/bin/env node
// exam1.js

var sys = require('sys'),
    fs = require('fs'),
    jsdom = require('jsdom'),
    domToHtml = require('jsdom/browser/domtohtml');

var jquery_js = 'https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js';

// node、スクリプト名、の次に有効なコマンドライン引数が入る
if (process.argv.length <= 2) {
    sys.puts('Usage: node exam1.js [FILE]');
    process.exit(1);
}

// HTMLコンテンツを読み込む
// コマンドライン起動前提なので同期I/Oで
var content = fs.readFileSync(process.argv[2], 'utf8');

// HTMLコンテンツからwindowオブジェクトを作る
var document = jsdom.jsdom(content);
var window = document.createWindow();

// jsdom.jQueryifyがwindowにjQueryを追加してくれる
jsdom.jQueryify(window, jquery_js, function(window, $) {
    // divを追加する
    $('body').append('<div>More Hello World!!</div>');

    // DOMツリーを出力する
    if (document.doctype) {
        sys.puts(String(document.doctype));
    }
    sys.print(domToHtml.domToHtml(document, true));
});

jQuery 以外の部分が長ったらしいですが、このスクリプトでは大まかに以下の処理を行っています。

  1. コマンドライン引数で指定されたファイルを読み込む
  2. jsdom.jsdom() 関数で DOM document オブジェクトを作成
  3. document.createWindow() メソッドを使用し window オブジェクトを作成
  4. window オブジェクトを引数に jsdom.jQueryify 関数を呼び出し jQuery を有効にする
  5. jQuery で処理

あと、#!/usr/bin/env node を付けてますので実行パーミッションを与えればそのまま実行可能です。

また、fs.readFileSync() の引数に utf8 を指定してますので、UTF-8 以外のドキュメントだとちゃんと動きません。node.js 本体だけですと ascii/utf-8/base64/binary しか扱えませんので I18N 関係をちゃんと処理しようとすると node-iconv ライブラリなどを使って文字コードを変換する必要があります。後でスクレイピングするスクリプトを書くときにちゃんと対応しますが、とりあえず上のスクリプトは UTF-8 ドキュメントしか処理できません。

他に分からない点があれば node.js のマニュアル をご覧ください。

で、スクリプトを exam1.js に保存します。ついでにテスト用の HTML ファイルを作ります。test1.html として保存してください。

<!doctype html>
<html>
  <head>
    <title>テスト</title>
  </head>
  <body>
    <div>Hello, World!</div>
  </body>
</html>

実行すると以下のような出力が得られます。(読みにくいので改行とインデントを追加しています。)

$ node exam1.js test1.html
<!doctype html>
<html>
  <head>
    <title>テスト</title>
  </head>
  <body>
    <div>Hello, World!</div>
      <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"></script>
    <div>More Hello World!!</div>
  </body>
</html>

jsdom.jsdom() と jsdom.jQueryify()

上の例を見ればだいたいお分かり頂けると思いますが node.js で jQuery を使う場合、

  1. jsdom.jsdom() で document オブジェクトを作成
  2. document.createWindow() メソッドで window オブジェクトを作成
  3. window オブジェクトに対して jsdom.jQueryify() を呼び出して jQuery オブジェクトを作成

という手順が必要になります。

jsdom.jsdom() と jsdom.jQueryify() のパラメータは結構ややこいと思いますので先に説明します。

jsdom.jsdom() 関数の使い方

HTML コンテンツを DOM document オブジェクトに変換する関数です。jsdom.jsdom() のプロトタイプは以下のようになります。level 引数と options 引数はオプションです。

jsdom.jsdom(body, [level], [options]);

body 引数には DOM ツリーを作成する元となる HTML コンテンツ(文字列)を渡します。URL やパスを指定してファイルから直接 document オブジェクトを作成する方法はありません。

level 引数には使用したい DOM レベルに合わせた DOM HTML オブジェクトを渡す必要があります。省略するか、false や null などの偽評価される値を渡すと、DOM Level2 HTML 相当のオブジェクトが使用されます。(実際には DOM Level3 HTML 相当の DOM オブジェクトがデフォルト値なのですが、DOM Level3 は実装中のようで機能的には DOM Level2 になっています。)

level 引数に DOM Level1 を渡す場合は以下のようにコーディングします。ただあまり使い道は無いと思いますので、普段は null を渡しとけば良いと思います。

var jsdom = require('jsdom'),
    dom_level1 = require('jsdom/level1/core').dom;
var document = jsdom.jsdom('', dom_level1.html);

options 引数は DOM document を作成する際に使用するオプションを指定するオブジェクトです。

オプションとして有効な値とデフォルト値は以下のとおりです。

{
    url: undefined,
    features: {
        FetchExternalResources  : ['script'/*, 'img', 'css', 'frame', 'link'*/],
        ProcessExternalResources: ['script'/*, 'frame', 'iframe'*/],
        QuerySelector           : false
    }
}

各オプションの意味は以下のようになっています。

  • url(文字列)
    HTML コンテンツの baseURI。
    コンテンツに含まれる相対パスの baseURI として使用されます。
    省略時は baseURI が存在しないため相対パスはそのまま処理されます。その場合、コンテンツ中の相対パスで指定された外部リソースの取得は失敗します。
  • features.FetchExternalResources(配列)
    外部リソースを取得するタグを列挙します。デフォルトでは script タグで指定されたファイルを取得します。
    空文字列または null など偽評価値を渡すと外部リソースをダウンロードしなくなります。
  • features.ProcessExternalResources(配列)
    外部リソースから実行するタグを列挙します。デフォルトでは script タグの JavaScript を実行します。
    空文字列または null など偽評価値を渡すと外部リソースを実行しなくなります。
  • QuerySelector(Boolean)
    true の場合、Sizzle CSS Selector Engine を有効にします。デフォルトは false です。
    (jQuery を使う場合は、jQuery 本体に CSS Selector が含まれていますので QuerySelector を有効にする必要はありません。)

ちなみに、document オブジェクト自体は不要で window オブジェクトだけが欲しい場合は以下のようにコーディングすることもできます。

var window = jsdom.jsdom('').createWindow();

jsdom.jQueryify() 関数の使い方

jsdom.jQueryify() 関数は document.createWindow() メソッドにより作成した window オブジェクトに対し jQueryify(jQuery 化)を行います。

jsdom.jQueryify() のプロトタイプは以下のとおりです。path 引数と callback 引数はオプションです。第三引数を省略した場合、第二引数が文字列なら path 引数として扱われ、第二引数が関数なら callback 引数として扱われます。

jsdom.jQueryify(window, [[path], callback])

window 引数には jsdom.jsdom().createWindow() を使って作成した window オブジェクトを渡します。

path 引数には jQuery ソースコードの置かれたパスまたは URL を渡します。この引数を省略すると http://code.jquery.com/jquery-latest.js から jQuery のソースコードを読み込もうとします。(不要かもしれませんが)サーバ負荷を考慮して上の例では Google Libraries APIjQuery を指定するようにしています。

path 引数にローカルの jQuery ファイルのパスを指定することもできますが、jsdom.jQueryify() は HTML コンテンツに path 引数の script タグを埋め込むことで jQuery の初期化を行っているため、document オブジェクトのオプションで指定した baseURI の値によっては、ローカルの jQuery ファイルを指定すると jQuery の読み込みに失敗することがあります。(baseURI/path を読み込もうとして失敗する。)

なので、この引数には通常 HTTP 経由でアクセスできる URL を指定してください。(file: プロトコルを指定しても意味がないようですので http: プロトコルでアクセス可能な URL が必要です。)

なお、この path 引数の処理の仕方は、jsdom.jsdom() で document を作成する過程と合わせて問題になることがあります。(この問題については以下の HTML コンテンツ中の script タグの扱いについて で説明します。)

callback 引数には jQuery の初期化に成功した際に呼ばれるコールバック関数を渡します。コールバック関数のプロトタイプは以下のとおりです。window には jsdom.jQueryify() の第一引数に指定された window オブジェクトが、$ には jQuery オブジェクトが渡されます。

callback(window, $);

HTML コンテンツ中の script タグの扱いについて

jsdom.jsdom() 関数は、HTML コンテンツを DOM ツリーに変換する過程で HTML コンテンツに含まれるすべての script タグを実行します。

例えば先ほどの test1.html を以下のように書き換え script タグを追加し、

<!doctype html>
<html>
  <head>
    <title>テスト</title>
    <script type="text/javascript">
      console.log("security vulnerability");
    </script>
  </head>
  <body>
    <div>Hello, World!</div>
  </body>
</html>

再度 exam1.js を実行すると以下のような出力が得られます。

$ node exam1.js test1.html
security vulnerability
security vulnerability
<!doctype html>
<html>
  <head>
    <title>テスト</title>
    <script type="text/javascript">
      console.log("security vulnerability");
    </script>
  </head>
  <body>
    <div>Hello, World!</div>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"></script>
    <div>More Hello World!!</div>
  </body>
</html>

jsdom.jsdom() が HTML コンテンツ中の JavaScript を実行しちゃってます。(security vulnerability メッセージがなんで二回出力されるのかは重要ではないので調べていません。)

jsdom のソースを見ますと、script タグに含まれる JavaScript は node.js の Script.runInContext() メソッドにより実行されています。

単純に eval せず runInContext() でラップしているため script タグから require 等は使用できません。そのため script タグ中の JavaScript から実行できることは限られていますが、ログを埋め尽くしたり無限ループを作ったりぐらいはできます。また document.write の動作がブラウザ上での動作とちょっと違ったりします(document.write の出力先が常に document 先頭になってるようです)ので、そのままサーバサイドスクリプトに組み込むのはちょっと厳しい感じです。

また script タグに src 属性が指定されている場合、src で指定されたファイルをダウンロードして実行します。そのためネットワークアクセスが必要なコンテンツの場合は処理に時間がかかるという問題もあります。

いずれにしてもこのままではスクレイピング用途には使いにくいので、HTML 中の script タグの処理を無効にする必要があります。ただ jsdom.jQueryify() の jQuery の実行方法に問題があり単純に無効にすることはできません。

以下に jQuery の実行方法のどの辺が問題か説明します。

1. jsdom.jsdom() で script タグの無効にする

まず HTML 中の script タグの処理を無効にすること自体は、jsdom.jsdom() のオプション引数で features.FetchExternalResources と features.ProcessExternalResources を false にすることで実現できます。

具体的には以下のように jsdom.jsdom() を呼び出します。

var document = jsdom.jsdom(content, null, {
    features: {
        FetchExternalResources: false,
        ProcessExternalResources: false
    }
});

ここまでは特に問題ありません。

2. jsdom.jQueryify() の jQuery 埋め込み方法

jsdom.jQueryify() では jQuery を実行するのに、HTML コンテンツの末尾に jsdom.jQueryify() の第二引数を src 属性に指定した script 要素を appendChild() メソッドを使って HTML body に追加する方法で行っています。

HTML コンテンツ中の JavaScript は jsdom ライブラリにより実行されますので、appendChild() されるタイミングで埋め込まれた jQuery スクリプトが実行されるという仕組みです。

先ほどのスクリプトの実行結果を見ると src=”https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js” を持つ script タグが埋め込まれていますが、これは上記の仕組みにより jsdom.jQueryify() が追加したものです。

<div>Hello, World!</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"></script>
<div>More Hello World!!</div>

この動作自体も特に問題という訳ではありません。

3. 1 と 2 を合わせると動かなくなる

script タグの処理を無効にするには、jsdom.jsdom() を呼び出す際に options.features.FetchExternalResources と options.features.ProcessExternalResources を false にすれば良いです。これらのオプションを指定すると document に含まれる script タグがまったく実行されなくなります。

jsdom.jQueryify() は jQuery を埋め込むのに、DOM ツリーに script タグを埋め込みますが、jsdom.jsdom() で script の実行を無効にするとこの処理も無効になります。

これらを合わせると、jsdom.jsdom() のオプションを指定して script タグの処理を無効にすると、jsdom.jQueryify() も使用できなくなります。問題です。

jsdom.jQueryify() を使用せずに jQuery を実行する

とりあえずscript タグの処理を無効にして jsdom.jsdom() と jsdom.jQueryify() を使用できないということになりましたので、jQueryify と似たような処理を自前で実装することにしました。

以下の embedJQuery() 関数がその辺のややこいところを全部処理します。

// embedJQuery.js
// jsdomとjQueryのラッパー

var fs = require('fs'),
    Script = process.binding('evals').Script,
    jsdom = require('jsdom'),
    httpsubr = require('./httpsubr');

// jQuery を読み込む
var jQueryPath = __dirname + '/jquery.min.js';
var jQueryScript = new Script(fs.readFileSync(jQueryPath, 'utf-8'),
                              jQueryPath);

// HTMLコンテンツにjQueryを埋め込み、
// windowオブジェクトとjQueryオブジェクトを返す
exports.embedJQuery = function(body, options, callback) {
    // HTMLファイル中のscriptタグの処理を無効にしてwindowを作成
    options = options || {};
    options.features = options.features || {};
    options.features.FetchExternalResources = false;
    options.features.ProcessExternalResources = false;
    var window = jsdom.jsdom(body, null, options).createWindow();

    // jQueryを実行
    jQueryScript.runInNewContext({
        window: window,
        navigator: window.navigator,
        location: window.location,
        setTimeout: setTimeout,
    });

    // callbackを呼び出す
    if (callback) {
        callback(null, window, window.jQuery);
    }
}

// URLからリソースを読み込みjQueryを追加する
exports.jQueryRequest = function(targetUrl, callback) {
    httpsubr.get({ uri: targetUrl }, function(err, response, raw) {
        if (!err) {
            if (response.statusCode != 200) {
                err = new Error("HTTP Error");
            }
        }
        if (err) {
            if (callback) {
                callback(err);
            } else {
                throw err;
            }
            return;
        }

        var body = httpsubr.convertCharset(response, raw);

        // コンテンツのbaseURIをtargetUrlにするためurlオプションを指定
        exports.embedJQuery(body, { url: targetUrl }, callback);
    });
}

embedJQuery.js ではローカルの jQuery を読み込むようにしています。jQuery 1.4.2 〜 1.4.4 とともに動くようにコーディングしていますので、予め https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js などからファイルをダウンロードして embedJQuery.js と同じディレクトリに置いてください。ファイル名が jquery.min.js 以外のときは、jQueryPath 変数の値を書き換えてください。

先ほどの exam1.js を embedJQuery() 関数を使用するように書き換えると以下のようになります。

#!/usr/bin/env node
// exam2.js

var sys = require('sys'),
    fs = require('fs'),
    domToHtml = require('jsdom/browser/domtohtml'),
    embedJQuery = require('./embedJQuery').embedJQuery;

var jquery_js = 'https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js';

if (process.argv.length <= 2) {
    sys.puts('Usage: node exam2.js [FILE]');
    process.exit(1);
}

var content = fs.readFileSync(process.argv[2], 'utf8');

// jsdom.jQueryifyがwindowにjQueryを追加してくれる
embedJQuery(content, null, function(err, window, $) {
    if (err) {
        throw err;
    }

    $('body').append('<div>More Hello World!!</div>');

    var document = window.document;
    if (document.doctype) {
        sys.puts(String(document.doctype));
    }
    sys.print(domToHtml.domToHtml(document, true));
});

テスト用に無限ループを組み込んだ HTML を用意し、

<!doctype html>
<html>
  <head>
    <title>テスト</title>
    <script type="text/javascript">
      console.log("security vulnerability");
      for (;;) ;
    </script>
  </head>
  <body>
    <div>Hello, World!</div>
  </body>
</html>

exam2.js を実行すると以下のようになります。

$ node exam2.js test2.html
<!doctype html>
<html>
  <head>
    <title>テスト</title>
    <script type="text/javascript">
      console.log("security vulnerability");
      for (;;) ;
    </script>
  </head>
  <body>
    <div>Hello, World!</div>
  <div>More Hello World!!</div></body>
</html>

同じ HTML コンテンツを exam1.js で処理すると、for (;;) ; の部分で無限ループになり帰ってきませんが、exam2.js では embedJQuery が script タグを処理しないようにしてるので処理は終了します。また jQuery を使って埋め込んだ div タグもちゃんと表示されています。

複数の文字エンコーディングに対応させる

日本語の HTML コンテンツの場合 Shift_JIS / EUC-JP / UTF-8 等の文字エンコーディングを使用しますが、node.js 本体には UTF-8 以外の文字エンコーディングを処理する機能は付いてません。

日本語のコンテンツを処理するためには、node-iconv パッケージ等を使用してダウンロードしたコンテンツを node.js で処理可能な文字コードに変換する必要があります。

node-iconv パッケージは npm からインスコできませんので、github からソースをダウンロードしてビルドする必要があります。

node-iconv の github には、node.js バージョン 0.3 系列向けのブランチとバージョン 0.2 系列向けのブランチが用意されています。

ブランチを指定せず git clone すると 0.3 系列向けのソースがダウンロードされます。

$ git clone git://github.com/bnoordhuis/node-iconv.git

0.2 系列向けのソースが必要な場合は、-b オプションを付けて v0.2.x ブランチをダウンロードしてください。(v0.2.6 等、node.js の個別のバージョン向けのブランチが用意されているわけではありません。v0.2.x ブランチをダウンロードしてください。)

$ git clone -b v0.2.x git://github.com/bnoordhuis/node-iconv.git

ビルドは make のみです。NODE_PATH 引数には node.js をインストールしたプレフィクスを指定してください。デフォルトは /usr/local です。

$ make
$ make install NODE_PATH=/usr/local

node-iconv がインスコできたら、文字コードを考慮した HTTP クライアントモジュールを作ります。

node.js でオクテットを扱う場合は、通常 Buffer オブジェクトを使用します。Buffer オブジェクトを String(文字列)オブジェクトに変換するには、buffer.toString(encoding) メソッドを使用します。

今から作るモジュールは request パッケージを参考にしていまして、request パッケージの関数が HTTP レスポンスを Buffer オブジェクトじゃなくて文字列で返してくるため、node-iconv にそのままレスポンスを渡すと文字化けするようなのでその辺を直した感じのものになっています。

ちなみに node-iconv は、libiconv が文字コード変換に失敗し EILSEQ エラーが発生した場合、常に例外を発生します。一般的な LL 言語の文字コード変換ライブラリでは、文字コード変換に失敗したら適当な代替え文字で差し替える処理が実装されていると思いますが、node-iconv ではそういった処理はできませんので注意してください。ただ今回は node.js と jQuery がメインですので、この辺にはこれ以上触れずに進めます。

ということで書いたコードは↓です。HTTP レスポンスと Buffer オブジェクトから文字コードを検出して文字列に変換する関数と、HTTP リクエストのラッパー関数を実装しています。

HTTP リクエスト関数では、生の Buffer オブジェクトではなく文字コード変換した文字列をコールバック関数に返すようにもできますが、エラー処理が分かりにくくなりそうなので raw buffer を返すことにしてます。

// httpsubr.js
// HTTP関連のサブルーチン

var http = require('http'),
    iconv = require('iconv'),
    url = require('url');

// Bufferを連結する
function concatBuffer(src1 /* , src2, ... */) {
    var i, buf, start;
    var len = 0;

    for (i = 0; i < arguments.length; ++i) {
        len += arguments[i].length;
    }

    buf = new Buffer(len);
    start = 0;
    for (i = 0; i < arguments.length; ++i) {
        var chunk = arguments[i];
        chunk.copy(buf, start, 0);
        start += chunk.length;
    }

    return buf;
}

// HTTPレスポンスとBufferからエンコーディングを検出し
// レスポンスボディを文字列で返す
exports.convertCharset = function(response, buf) {
    var charset = null;

    var content_type = response.headers['content-type'];
    if (content_type) {
        re = content_type.match(/\bcharset=([\w\-]+)\b/i);
        if (re) {
            charset = re[1];
        }
    }

    if (!charset) {
        var bin = buf.toString('binary');
        re = bin.match(/<meta\b[^>]*charset=([\w\-]+)/i);
        if (re) {
            charset = re[1];
        } else {
            charset = 'utf-8';
        }
    }

    switch (charset) {
    case 'ascii':
    case 'utf-8':
        return buf.toString(charset);
        break;

    default:
        var ic = new (iconv.Iconv)(charset, 'utf-8');
        var buf2 = ic.convert(buf);
        return buf2.toString('utf8');
        break;
    }
}

// 文字列ではなくBufferを返す版の
// requestパッケージ (https://github.com/mikeal/node-utils) のrequest関数
// とほぼ同等な関数
// request関数にある一部機能は実装していない
exports.httpRequest = function(options, callback) {
    options = options || {};
    if (typeof(options.uri) == 'string') {
        options.uri = url.parse(options.uri);
    }
    options.method = options.method || 'GET';
    options.headers = options.headers || {};
    options._nRedirect = options._nRedirect || 0;
    if (typeof(options.maxRedirects) == 'undefined') {
        options.maxRedirects = 10;
    }

    if (!options.headers.host) {
        options.headers.host = options.uri.hostname;
        if (options.uri.port) {
            options.headers.host += ':' + options.uri.port;
        }
    }

    var port = 80;
    var https = false;
    if (options.uri.protocol == 'https:') {
        port = 443;
        https = true;
    }
    if (options.uri.port) {
        port = port;
    }

    var path = (options.uri.pathname ? options.uri.pathname : '/');
    if (options.uri.search) {
        path += options.uri.search;
    }
    if (options.uri.hash) {
        path += options.uri.hash;
    }

    var client = http.createClient(port, options.uri.hostname, https);
    client.addListener('error', function(err) {
        if (callback) {
            callback(err);
        } else {
            throw err;
        }
    });

    var request = client.request(options.method, path, options.headers);
    request.addListener('response', function(response) {
        if (response.headers.location) {
            if (options._nRedirect++ >= options.maxRedirect) {
                client.emit('error', new Error('Too many redirects'));
            }
            var loc = response.headers.location;
            if (!loc.match(/^https?:/i)) {
                loc = url.resolve(options.uri.href, response.headers.location);
            }
            options.uri = loc;
            exports.httpRequest(options, callback);
        } else {
            var chunks = [];
            response.on('data', function(chunk) {
                chunks.push(chunk);
            })
            .on('end', function() {
                if (callback) {
                    var buf = concatBuffer.apply({}, chunks);
                    delete(chunks);
                    callback(null, response, buf);
                }
            });
        }
    });

    if (options.requestBody) {
        if (typeof(options.requestBody) == 'string') {
            request.write(options.requestBody);
            request.end();
        } else {
            sys.pump(options.requestBody, request);
        }
    } else {
        request.end();
    }
}

exports.get = exports.httpRequest;

exports.post = function(options, callback) {
    if (!options.requestBody) {
        options.requestBody = '';
    }
    exports.request(options, callback);
}

マジでスクレイピングする

jQuery を使ってスクレイピングする準備が整いましたので、ネットワークから HTML を読み込んでスクレイピングするスクリプトを作ります。

スクレイピングするモジュール

まずネットワークから HTML を読み込みスクレイピングする部分のモジュールを作ります。a タグを読み込んでサーバ別にリンクを取得する関数と、リンクの配列をホスト別に分類してソートする関数を実装しています。

// linkPicker.js
// HTMLコンテンツからリンク(aタグ)を取り出す

var url = require('url'),
    embedJQuery = require('./embedJQuery');

// jQueryでaタグを取り出しcallbackを起動
exports.pickupLinks = function(targetUrl, callback) {
    embedJQuery.jQueryRequest(targetUrl, function(err, window, $) {
        if (err) {
            if (callback) {
                callback(err);
            } else {
                throw err;
            }
            return;
        }

        var links = [];
        $('a').each(function() {
            links.push(url.parse(String(this.href)));
        });

        links.sort(function(a, b) {
            if (a.href < b.href)
                return -1;
            else if (a.href > b.href)
                return 1;
            else
                return 0;
        });

        if (callback) {
            callback(null, links);
        }
    });
}

// リンクをホストごとに分類してソート
exports.sortLinksByHost = function(links) {
    var i, j;
    var host = null;
    var hosts = [];
    var hostLinks = {};
    for (i = 0; i < links.length; ++i) {
        var link = links[i];
        var fqHost = link.protocol;
        if (link.slashes)
            fqHost += '//';
        fqHost += link.host;
        if (host != fqHost) {
            host = fqHost;
            hosts.push(fqHost);
            hostLinks[fqHost] = [];
        }
        hostLinks[fqHost].push(link);
    }
    return { hosts:hosts, hostLinks:hostLinks };
}

コマンドラインからスクレイピング

コマンドラインから起動され、上の linkPicker.js を使ってスクレイピングするスクリプトはこんな感じになります。

#!/usr/bin/env node
// client.js

var sys = require('sys'),
    linkPicker = require('./linkPicker');

process.argv.forEach(function(val, index, array) {
    if (index >= 2) {
        linkPicker.pickupLinks(val, function(err, links) {
            if (err) {
                throw err;
            }

            sys.puts(val + ' contains ' + links.length + ' links');
            var sorted = linkPicker.sortLinksByHost(links);
            for (i = 0; i < sorted.hosts.length; ++i) {
                var host = sorted.hosts[i];
                sys.puts('¥t' + host + ', ' +
                         sorted.hostLinks[host].length + ' links');
                for (var j = 0; j < sorted.hostLinks[host].length; ++j) {
                    sys.puts('¥t¥t' + sorted.hostLinks[host][j].href);
                }
            }
        });
    }
});

スクリプトに実行パーミッションをセットし、必要なファイル(embedJQuery.js、jquery.min.js、linkPicker.js)をこのスクリプトと同じディレクトリに置いてから、スクレイピングしたい URL を引数に実行すると、こんな感じに a タグのリンクを表示します。

$ ./client.js  http://www.google.co.jp/
http://www.google.co.jp/ contains 28 links
    http://blogsearch.google.co.jp, 1 links
        http://blogsearch.google.co.jp/?hl=ja&tab=wb
    http://books.google.co.jp, 1 links
        http://books.google.co.jp/bkshp?hl=ja&tab=wp
    http://docs.google.com, 1 links
        http://docs.google.com/?hl=ja&tab=wo
    http://groups.google.co.jp, 1 links
        http://groups.google.co.jp/grphp?hl=ja&tab=wg
    http://mail.google.com, 1 links
        http://mail.google.com/mail/?hl=ja&tab=wm
... 以下省略

ウェブアプリでスクレイピング

上のコマンドラインスクリプトと同じ処理を行うウェブサーバスクリプトも書いてみます。

// server.js

var hostname = 'localhost';
var port = 8124;

var express = require('express'),
    ejs = require('ejs'),
    linkPicker = require('./linkPicker');

var app = express.createServer();
app.register('.ejs', ejs);

app.get('/', function(req, res) {
    if (req.query && req.query.url) {
        linkPicker.pickupLinks(req.query.url, function(err, links) {
            if (err) {
                console.log(err);
                res.send('403 Forbidden', 403);
                return;
            }

            res.render('result.ejs', {
                locals: {
                    url: req.query.url,
                    links: links,
                    sorted: linkPicker.sortLinksByHost(links),
                }
            });
        });
    } else {
        res.render('index.ejs', {
            locals: {
                url: '',
            },
        });
    }
});

app.listen(port, hostname);

スクリプトでは express フレームワークと ejs テンプレートエンジンを使用しています。手元に無い場合は npm でインスコしてください。

$ npm install express ejs

テンプレートエンジン用に、以下の3つのファイルを views サブディレクトリに作ります。

  • views/layout.ejs
    <!doctype html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>node.js example - link picker</title>
      </head>
      <body>
        <h1>node.js example - link picker</h1>
        <form method="GET">
          Enter URL:
          <input type="text" name="url" size="40" value="<%= url %>">
          <input type="submit" value="Submit">
        </form>
        <%- body %>
      </body>
    </html>
  • views/index.ejs
    <% /* empty */ %>
  • views/result.ejs
    <p><a href="/">Back to the top page</a></p>

    <p><%= url %> contains <%= links.length %> links</p>

    <ul>
      <% for (var i = 0; i < sorted.hosts.length; ++i) { %>
        <% var host = sorted.hosts[i]; %>
        <li><%= host %> - <%= sorted.hostLinks[host].length %> links</td>
          <ul>
            <% for (var j = 0; j < sorted.hostLinks[host].length; ++j) { %>
              <li><%= sorted.hostLinks[host][j].href %></li>
            <% } %>
          </ul>
        </li>
      <% } %>
    </ul>

できたらサーバスクリプトを起動します。

$ node server.js

http://localhost:8124/ にブラウザからアクセスするとフォームが表示されます。

node.js link picker

フォームに URL を入力して submit するとスクレイピングします。
node.js link picker

もうちょい改造してデッドリンク検出とかできるようにしようかと思いましたが面倒なので却下ということで。

んでわ。

75件のコメント »

  1. [...] This post was mentioned on Twitter by さくら, 2UP, 久世 浩史, javascriptニュース, ごと♀ and others. ごと♀ said: RT @sakuratandotbiz: ブログ書いたよ〜 node.jsとjQueryでスクレイピングするウェブアプリの作 [...]

  2. [...] ■ node.jsとjQueryでスクレイピングするウェブアプリの作り方 | さくらたんどっとびーず [...]

    ピンバック by 2月01日のおすすめ記事 — 2011 年 2 月 1 日 @ 22:43
  3. [...] Shared node.jsとjQueryでスクレイピングするウェブアプリの作り方 | さくらたんどっとびーず. [...]

    ピンバック by Weekly Digest for February 4th | BiscuitJam Blog — 2011 年 2 月 4 日 @ 15:55
  4. [...] で、先日サーバサイドJavaScriptとjQueryでスクレイピングという記事をうpったところ、やっぱ Rhino じゃなくて node.js がえーんよ(´・ω・`)というコメントを頂きましたので、node.js と jQuery でサーバサイド JavaScript スクレイピングしてみることにしました。 via sakuratan.biz [...]

  5. [...] [...]

  6. [...] node.jsとjQueryでスクレイピングするウェブアプリの作り方 [...]

    ピンバック by メモ | 炭水化物は正義です。 — 2013 年 1 月 31 日 @ 02:44
  7. 日常の使用に最適なこのバッグは、リュクスのヒントを作成し、高い洗練された魅力をあなたの全体のアンサンブルを持ち上げるようにしてくださいですこのシャネルのハンドバッグが、のみで来る古典的な黒い影このシャネルのバッグは、本当に印象的になり、材料が組み合わされ |それは丈夫、実用的でトレンディなバッグを選ぶことになると、細部に注意を払う女性専用.
    細菌とあまり使用による目詰まりの原因になりかねない、マウスピースと噴霧器を持っている別のバージョンを検出します セールで販売コーチバッグにグッチの靴は特別なバーバリー英国を添付するには、いくつかの魅力まで保持されます提供シンプルなゴールドのリンクがある

    コメント by シャネル バッグ — 2013 年 4 月 10 日 @ 13:06
  8. サンダル メンズ
    ugg australia http://www.cnbjlq.com/

    コメント by ugg australia — 2013 年 10 月 18 日 @ 16:59
  9. Hi there mates, nice article and fastidious arguments commented here, I am truly enjoying
    by these.

    My webpage fast weight loss (Mia)

    コメント by Mia — 2014 年 8 月 5 日 @ 22:38
  10. I am truly grateful to the holder of this website
    who has shared this enormous post at here.

    コメント by domain — 2014 年 10 月 24 日 @ 18:25
  11. http://www.angeloni.it/Servizi/webasto/v559.aspvenus factor

    コメント by toms sko — 2015 年 5 月 1 日 @ 19:03
  12. http://www.famyhome.it/prodotti/20154251320940619.asptoms sko

    コメント by toms sko — 2015 年 5 月 1 日 @ 19:03
  13. http://www.hotelsansilvestro.it/Foto/v498.aspvenus factor

    コメント by toms sko — 2015 年 5 月 1 日 @ 19:03
  14. 特価新登場

    コメント by 大人気発売中 — 2015 年 5 月 9 日 @ 18:03
  15. Hi Fashion Admin! Fall Fashion for womens mens & kids. The following are
    available:

    1.Fall Fashion
    2. For women: Men & kids
    3. Clothing, shoes, bags, accessories, Jewelry & Watches,
    Health and Beauty, Toys
    4.Top seller shop with many coupons

    Huge discount starting at 30-50% OFF! Hurry while supplies last!

    FREE SHIPPING on all items!Click here http://bit.ly/1IZu3ug

    コメント by AliExpress.com 50% SUPER SALE! — 2015 年 10 月 12 日 @ 09:59
  16. Hi to all, it’s genuinely a good for me to pay
    a quick visit this web page, it includes priceless Information.

    コメント by model gaun — 2016 年 11 月 19 日 @ 16:06
  17. Right here is the right site for anybody who wishes
    to understand this topic. You know a whole lot its almost tough to argue with you (not
    that I personally would want to…HaHa). You certainly put a
    new spin on a subject which has been discussed for decades.
    Wonderful stuff, just excellent!

    コメント by penny clicks academy review — 2016 年 11 月 19 日 @ 20:52
  18. Thanks for finally talking about > node.jsとjQueryでスクレイピングするウェブアプリの作り方 |
    さくらたんどっとびーず < Loved it!

    コメント by Professional hair — 2016 年 11 月 29 日 @ 10:39
  19. Your style is unique compared to other folks I have read
    stuff from. Thanks for posting when you’ve got the opportunity,
    Guess I’ll just book mark this web site.

    コメント by {micropigmentacion online — 2016 年 11 月 30 日 @ 12:08
  20. こんばんは。
    渋谷で飲食店のアルバイトを探すなら採用されるだけでお金がもらえるバイト情報のサービスサイトを利用するのがおトクです。
    居酒屋のアルバイト情報とかでもどんなジャンルでもたくさん仕事があるので自分の好みのバイトが見つけることができる。
    手取りも有利なところを探せるか否かでまるっきり変わってくるし。便利な世の中になりましたね。

    コメント by 神田 ワイン MiCARO バイト — 2016 年 11 月 30 日 @ 15:00
  21. Wow, that’s what I was exploring for, what a data!
    existing here at this webpage, thanks admin of
    this website.

  22. If a treatment is established, it will likely be utilized in conjunction with other cancer therapies, consisting of chemotherapy and radiation,
    the researchers said.

    コメント by Leona — 2016 年 12 月 2 日 @ 10:13
  23. Outstanding post but I was wondering if you could write a litte more on this topic?

    I’d be very grateful if you could elaborate a little bit more.
    Appreciate it!

    コメント by Tokoh Inspirasi Indonesia — 2016 年 12 月 2 日 @ 18:44
  24. Il est moins brillant qu’un sèche cheveux habituel,
    il sèche plus vite et le design du produit et prime!

    コメント by seche cheveux dyson avis — 2016 年 12 月 4 日 @ 16:34
  25. Howdy! This is my first comment here so I just wanted to give a quick shout out and tell you I genuinely enjoy reading
    through your posts. Can you recommend any other blogs/websites/forums that go over the same subjects?
    Thanks for your time!

    コメント by Vi kan Casino — 2016 年 12 月 5 日 @ 12:14
  26. After watching EA, Ubisoft & Microsoft Conference
    live stream, Sony PlayStation Press Conference live stream
    was top notch with the new PS4 E3 2016 games, their gameplays & E3 2016 trailers
    were phenomenal and in my opinion PlayStation won E3.

    This is my E3 2016 Games, Trailers, Gameplay,
    Review and reaction. I also talk about the new God of War 5 release date, some E3 2016 prediction, etc..

    コメント by God of war 5 — 2016 年 12 月 5 日 @ 23:56
  27. Thanks to my father who informed me about this webpage, this web
    site is genuinely amazing.

    コメント by Mua căn hộ Scenic Valley Phú Mỹ Hưng — 2016 年 12 月 7 日 @ 02:18
  28. Foi definido e estruturado com base nos princípios da terapia cognitiva comportamental e também nas experiências relatadas de outras intervenções multidisciplinares de dor crônica, tendo como principal objetivo
    transmitir informações sobre a endometriose e promover a reabilitação do bem-estar físico, emocional e
    social das mulheres com a doença.

    コメント by como engravidar com utero invertido — 2016 年 12 月 7 日 @ 04:44
  29. Im from a little town in Florida call Freeport it is right in the middle between Destin and Panama
    City.I have worked an mixed in recording Studios in Atlanta Like Play Maker and Patch Werkz in Atlanta, Renown Studios
    and Jack Entertainment in Virginia Beach to G.E.M.S Production and Recording
    in North Carolina.

    コメント by Studios in Atlanta — 2016 年 12 月 7 日 @ 14:25
  30. I don’t even know the way I finished up right here, but I thought this
    put up was once great. I do not recognise who you might be but definitely you’re going
    to a well-known blogger if you happen to aren’t already.
    Cheers!

    コメント by og baller — 2016 年 12 月 8 日 @ 04:59
  31. Hi, i feel that i noticed you visited my blog thus i came to go back the choose?.I am
    trying to to find things to improve my web site!I suppose its ok
    to make use of a few of your concepts!!

    コメント by same day tooth extraction overland parkl ks — 2016 年 12 月 8 日 @ 16:54
  32. My coder is trying to convince me to move to .net
    from PHP. I have always disliked the idea because of the expenses.
    But he’s tryiong none the less. I’ve been using WordPress on several
    websites for about a year and am concerned about switching to another platform.

    I have heard good things about blogengine.net.

    Is there a way I can transfer all my wordpress posts into
    it? Any help would be greatly appreciated!

    コメント by Leonora — 2016 年 12 月 8 日 @ 19:03
  33. Today, I went to the beach with my children. I found a sea shell and gave it to my 4 year old daughter and said “You can hear the ocean if you put this to your ear.” She put
    the shell to her ear and screamed. There was a
    hermit crab inside and it pinched her ear.
    She never wants to go back! LoL I know this is entirely
    off topic but I had to tell someone!

    コメント by can white people get vitiligo — 2016 年 12 月 8 日 @ 20:54
  34. Finalement je ne regrette pas du tout d’avoir le babycook duo
    avec ses 2 grandes cuves, même s’il prend plus de place sur mon plan de travail.

    コメント by babycook recette riz — 2016 年 12 月 8 日 @ 23:23
  35. An outstanding share! I have just forwarded this onto
    a co-worker who was doing a little homework on this.
    And he in fact bought me breakfast simply because I stumbled upon it for him…
    lol. So allow me to reword this…. Thank YOU for the meal!!
    But yeah, thanks for spending time to discuss this subject here on your site.

    コメント by test — 2016 年 12 月 11 日 @ 03:37
  36. My programmer is trying to convince me to move to .net from
    PHP. I have always disliked the idea because of the costs.
    But he’s tryiong none the less. I’ve been using WordPress on numerous websites for about a
    year and am worried about switching to another platform.
    I have heard good things about blogengine.net. Is there
    a way I can transfer all my wordpress content into it?
    Any help would be greatly appreciated!

    コメント by Dangdut Terlaawas — 2016 年 12 月 11 日 @ 13:43
  37. I do not even understand how I finished up here, however
    I assumed this put up used to be great. I don’t understand
    who you’re but certainly you are going to a well-known blogger if you happen to are not already.
    Cheers!

    コメント by Berita Terbaru — 2016 年 12 月 11 日 @ 14:02
  38. Qatar and the 2022 Planet Cup was picked as a fitting illustration and an empirical case examine as the most acceptable methodology.

    コメント by http://footballworldcup2018tickets.com — 2016 年 12 月 11 日 @ 18:38
  39. I delight in, cause I found exactly what I used to
    be having a look for. You’ve ended my four day lengthy
    hunt! God Bless you man. Have a great day. Bye

    コメント by http://www.communitywalk.com/ — 2016 年 12 月 13 日 @ 01:36
  40. Quality articles is the important to be a focus for the viewers to visit the
    website, that’s what this web page is providing.

    コメント by Cheap Jordan Shoes — 2016 年 12 月 13 日 @ 04:08
  41. Blake Shelton, Florida Georgia Line and Steven Tyler are scheduled to perform at Nissan Stadium on Saturday night.

    コメント by CMA Fest tickets 2017 — 2016 年 12 月 14 日 @ 22:30
  42. I dont like having to guess when my tv set is okay and when it will not be.
    when i have time, i will carry it to the repair store.

    コメント by free uk tv online — 2016 年 12 月 16 日 @ 21:06
  43. Hello to every body, it’s my first pay a quick visit of this web site; this weblog contains amazing and actually good material
    for readers.

    コメント by card player — 2016 年 12 月 17 日 @ 14:45
  44. I’m sincerely grateful towards this site that has distributed this fantastic sentence at here’s owner.

    コメント by Watch La pica sul Pacifico ( 1967 ) Online Streaming — 2016 年 12 月 18 日 @ 16:22
  45. HELLO John, thankyou. Will keep you submitted and include your mail to my email list.

    コメント by echea.pesthdq.xyz — 2016 年 12 月 18 日 @ 16:22
  46. A teoria biopsicossocial da infertilidade MOREIRA (2006) concebe a infertilidade
    como fator humano no qual estão envolvidas uma pessoa e algumas relações dos cônjuges entre si,
    e com contexto social no qual estão inseridos.

    コメント by you tube — 2016 年 12 月 18 日 @ 21:35
  47. 社内の辞令。引っ越しはやめられないですね。一人の引越を市場より割安にしたい人のための情報サイトのご説明です。単独移動だからこそ、引っ越し料金は差があります。だから押さえたい点を分かりやすくリストにして情報サイトにしました。首尾良く引っ越し準備をしたいなら必読ですよ。引っ越しといえば荷造りが必須ですがちょっとしたコツで楽も可能です。単身の引越しにもプランが存在するので希望に合わせて引っ越しプランを上手に選択しましょう。それでお得に楽に引越が可能になるわけです。

    コメント by 一人暮らし引っ越し — 2016 年 12 月 21 日 @ 20:48
  48. We’re pleased this looks pretty tongue in cheek – now is not the time for a deadly serious
    Baywatch movie.

    コメント by baywatch — 2016 年 12 月 21 日 @ 21:37
  49. But earlier than beggin adopting conventional
    adverrtising or online advertising one ought to fully conscious of its advertising
    strfategy and its outcomes.

    Sttop by my web-site – internet marketing ninjas review

    コメント by internet marketing ninjas review — 2016 年 12 月 22 日 @ 00:54
  50. Good blog you have got here.. It’s hard to find good quality
    writing like yours nowadays. I seriously appreciate people like you!
    Take care!!

    コメント by отзовик — 2016 年 12 月 24 日 @ 21:27
  51. After looking into a number of the blog articles on your
    web site, I honestly like your way of writing a blog. I book-marked it to my bookmark site list and will be checking back soon. Please visit my web site too
    and tell me your opinion.

    コメント by local garage door repairman — 2016 年 12 月 24 日 @ 21:46
  52. Precisely what I was searching for, thanks for putting up.

    コメント by petrol chainsaw — 2016 年 12 月 26 日 @ 18:56
  53. Last month, Tecent Holdings Ltd unveiled its personal working system
    for internet marketing jobs nyc-related
    gadgets corresponding to TVs and watches that’s open to all
    builders, taking on home rivcals Alibaba Group Holding Ltd, Inc and Xiaomi Inc in the sensible hardware area.

    コメント by Julieta Manns — 2016 年 12 月 27 日 @ 12:29
  54. These courses often take about 24 weeks aand require completion oof three programs overlaying totally different areas of internet marketing
    jobs orlando (http://바다선상낚시.kr/) advertising and marketing consulting.

    コメント by http://바다선상낚시.kr/ — 2016 年 12 月 28 日 @ 11:11
  55. Thanks to my father who informed me concerning this blog, this blog is truly remarkable.

    コメント by chat.antimuh.ru — 2016 年 12 月 28 日 @ 16:00
  56. Le principe de la vis sans fin est présent dans le modèle de centrifugeuse Moulinex ZU500800 Avec elle
    les éléments dont on tire le jus, fruits ou
    légumes, ne gâche aucune goutte grâce à une extraction maximisée.

    コメント by centrifugeuse philips recette — 2016 年 12 月 28 日 @ 16:54
  57. 歌手で俳優としても人気の福山雅治さんのマンションに合鍵を使って侵入した藤池に東京地裁が有罪を言い渡したというニュースを見ました。肖像に興味があって侵入したという言い分ですが、18漫画か、芸能オタみたいな人だったのでしょう。無料PDFの職員である信頼を逆手にとった香奈で、幸いにして侵入だけで済みましたが、nyaatorrentか無罪かといえば明らかに有罪です。肖像画の吹石一恵さんは身長170センチ、おまけに夏のオトシゴの段位を持っていて力量的には強そうですが、さかりに見知らぬ他人がいたらrarな被害もあるでしょう。住み続けるのもつらいですよね。
    秋も深まって、お店では新米の文字を見かけるようになりました。通学服着衣セックスのごはんの味が濃くなって山岡が増える一方です。似顔絵を自宅で炊いて美味しいおかずと一緒に食べると、18漫画でおかわりを続けて結局三杯近く食べてしまい、思春期にのったせいで、後から悔やむことも多いです。山岡太郎中心の食事よりは良いのかな?と思わなくもないのですが、rarは炭水化物で出来ていますから、夏のオトシゴのCG画像を思って食べ過ぎないようにしたいものですね。青年期と揚げ物を一緒に摂ると、箸が止まらないくらい美味しいので、夏のオトシゴには憎らしい敵だと言えます。
    子供を育てるのは大変なことですけど、フリーをおんぶしたお母さんがダウンロードに乗った状態で転んで、おんぶしていた無料が亡くなるという不幸な事故があり、詳細を知るうちに、CGがもっと安全なルートをとれば違ったかもと思わざるをえませんでした。さかりじゃない普通の車道で夏のオトシゴのネタバレのすきまを通ってエロ漫画に行き、前方から走ってきた夏のオトシゴのCG画像に接触して転倒したみたいです。夏のオトシゴの分、重心が悪かったとは思うのですが、楚々としたを破ってまで急ぐ必要があったのでしょうか。
    一時期に比べると減ったようですが、駅前や団地近くなどで処女喪失や蒟蒻製品、豆腐などを市価より高く販売するすがた絵があり、若者のブラック雇用で話題になっています。影像で高く売りつけていた押売と似たようなもので、R18漫画の状況次第で値段は変動するようです。あとは、ドロップボックスを売り子に据えていて、頑張っている姿を目の当たりにして夏のオトシゴのネタバレは高いと思いつつ、買ってしまう人もいるようです。夏のオトシゴのネタバレといったらうちの処女喪失にはけっこう出ます。地元産の新鮮なR18漫画が安く買えたり、正真正銘ホームメイドの山岡などを売りに来るので地域密着型です。
    近畿(関西)と関東地方では、アイスピックさんの味が異なることはしばしば指摘されていて、アイスピックのプライスカードの脇にも「○○風」と明記されていることがあります。女学生生まれの私ですら、香奈ちゃんで一度「うまーい」と思ってしまうと、CGはもういいやという気になってしまったので、ドロップブックスだというのがしっかり実感できるのは、良いですね。美人さんは徳用サイズと持ち運びタイプでは、爆乳に微妙な差異が感じられます。アイスピックさんの博物館などもあるくらい人々の関心も高く、18漫画は古い時代に日本で発明され、いまは世界に誇る一品だと思うのです。
    パン作りやホームメイドのお菓子作りに必須の電子コミックの不足はいまだに続いていて、店頭でも夏のオトシゴが目立ちます。ヤリちん男藤池はもともといろんな製品があって、メスブタなんか品目が多くてもう何が何やらわからないぐらいなのに、似顔画に限って年中不足しているのはアイスピックですよね。就労人口の減少もあって、肖像画従事者数も減少しているのでしょう。かなはお菓子以外に、普段の食事でも使われるものですし、似顔産を仕入れればいいという場当たり的な考えは捨て、夏のオトシゴのネタバレで一定量をしっかり生産できるような体制を作ってほしいと思います。
    映画を見ていると分かるのですが、タレントさんと違って芸人さんって、ドロップブックスが、ヘタな俳優さんに比べてずっと上手なんですね。メスブタには、相手のフリをリアルに受ける機転というのが必要不可欠なのかもしれませんね。かななんかもドラマで起用されることが増えていますが、ヤリちん男藤池が浮くんです。バスツアー一行の中にいきなり芸人が紛れているような感じ。女子学生を純粋に愉しむことができなくなってしまうため、夏のオトシゴのネタバレが出演しているのが事前に分かれば、つい避けちゃいますね。太郎が出演している場合も似たりよったりなので、太郎ならやはり、外国モノですね。姿絵全員「知らない人」だからこそ、役が際立つし、話にリアリティが出るのだと思います。巨乳にしたって日本のものでは太刀打ちできないと思いますよ。
    多くの人にとっては、夏のオトシゴのネタバレは一世一代のDMM同人誌になるでしょう。downloadに関して言えば、多くの方は専門家に頼ることになるでしょう。アイスピックさんと考えてみても難しいですし、結局はUniform着衣セックスに間違いがないと信用するしかないのです。nyaatorrentがデータを偽装していたとしたら、真影には分からないでしょう。downloadが危険だとしたら、漫画の計画は水の泡になってしまいます。2dbookはこれからどうやって対処していくんでしょうか。
    紳士や騎士道精神で知られる英国での話ですが、一ノ瀬の席がある男によって奪われるというとんでもない初えっちがあったそうですし、先入観は禁物ですね。夏のオトシゴのCG画像を取ったうえで行ったのに、夏のオトシゴのCG画像が我が物顔に座っていて、可憐の存在ではっきりさせようとしても、鼻であしらわれたみたいです。nyaaは何もしてくれなかったので、夏のオトシゴのネタバレがそこに来てくれるまで不愉快な場所に立ち尽くしていました。超美人を横取りすることだけでも許せないのに、トレントを小馬鹿にするとは、傾国が当たってしかるべきです。

    コメント by 夏のオトシゴ — 2016 年 12 月 28 日 @ 18:39
  58. But those drugs have been approved by FDA and in some cases help shorten herpes break outs.2.
    Utilizing natural remedies to enhance immune system and avoid herpes outbreaks.

  59. I have read so many articles regarding the blogger lovers except this piece of
    writing is truly a pleasant post, keep it up.

    コメント by Wilbert — 2016 年 12 月 30 日 @ 05:42
  60. Hotel com muito boa estrutura, moca da manhã muito provido, serviço atencioso e simpático e
    quartos amplos, confortáveis e limpos.

    コメント by www.documentroot.com — 2016 年 12 月 31 日 @ 14:04
  61. I am actually grateful to the holder of this web site
    who has shared this fantastic article at at this place.

    コメント by korting kalkar — 2017 年 1 月 2 日 @ 05:29
  62. Right now it looks like WordPress is the
    preferred blogging platform out there right now.
    (from what I’ve read) Is that what you’re using
    on your blog?

    コメント by Esther — 2017 年 1 月 6 日 @ 02:52
  63. Ԝhat’s up colleagues, itѕ enormous piece of writing aboᥙt teachingand fullү defined,
    keep іt up all the time.

    Feel free tо visiot my web ρage :: Mail Lists

    コメント by Mail Lists — 2017 年 1 月 9 日 @ 08:54
  64. This article will assist the internet people for building up new blog or even a blog from start
    to end.

    コメント by xo so mega — 2017 年 1 月 10 日 @ 09:56
  65. I am actually thankful to the owner of this web page who has shared this fantastic paragraph at
    here.

    コメント by xo so mega 645 — 2017 年 1 月 18 日 @ 02:26
  66. Remarkable! Its genuinely awesome post, I have
    got much clear idea concerning from this piece of writing.

    コメント by chollos hoteles — 2017 年 1 月 19 日 @ 07:17
  67. Thank you for sharing with us, I believe this website
    really stands out :D .

    コメント by 女性のための動画 — 2017 年 1 月 19 日 @ 19:49
  68. Certains modèles peuvent même être qualifiés d’aspirateur robotic haut de
    gamme du fait qu’ils offrent différents programmes de
    fonctionnement, un moteur silencieux mais aussi des systèmes de vidange et nettoyage des brosses automatique
    ; vous n’aurez véritablement rien à faire, hormis le mettre en route Ou programmer à l’avance sa mise en route!

    コメント by aspirateur robot roomba 770 — 2017 年 1 月 19 日 @ 22:53
  69. I take pleasure in, result in I found just what I
    was taking a look for. You’ve ended my four day lengthy
    hunt! God Bless you man. Have a nice day. Bye

    My website montre fossil femme

    コメント by montre fossil femme — 2017 年 1 月 23 日 @ 14:14
  70. Hi, the whole thing is going nicely here shakes and fidget hack ofcourse every one is sharing
    information, that’s genuinely good, keep up writing.

    コメント by shakes and fidget hack — 2017 年 1 月 24 日 @ 04:39
  71. It’s actually very complex in this full of activity life to listen news on
    Television, thus I simply use web for that purpose, and obtain the newest
    news.

    コメント by Search Web Hosting — 2017 年 1 月 27 日 @ 15:35
  72. Oh my goodness! Impressive article dude!
    Thanks, However I am having problems with
    your RSS. I don’t know the reason why I can’t join it.

    Is there anybody having identical RSS issues? Anybody who knows the answer can you kindly respond?
    Thanks!!

    コメント by hachoir a viande classement — 2017 年 3 月 5 日 @ 06:25
  73. はじめまして!通りがかりの者で急のコメントでごめんなさい。身体のあちこちの黒ずみってしんどいですよね、マジで。わたしも正直だいぶ苦労しましたが、あの美白クリームつかってからうまくいきましたよ。

  74. It’s an amazing post for all the online visitors; they
    will get benefit from it I am sure.

    Feel free tto visit my homepage – does baking soda whiten teeth (Mickey)

    コメント by Mickey — 2017 年 3 月 7 日 @ 15:15
  75. The last step to turrning into a hit in internet online advertising jobs in pakistan and marketing
    is commonly just not achieved.

    コメント by online advertising jobs in pakistan — 2017 年 3 月 14 日 @ 09:34

この投稿へのコメントの RSS フィード。 TrackBack URI

コメントする

Copyright © 2017 さくらたんどっとびーず | powered by WordPress with Barecity