HackerNewsのスクレーパーを書いてみた

今話題のKoaについて書こうかとも思ったのですが・・・。
なんとなく最近、HNを中心に見るようになったので、HNスクレーパーを作ってみました。

皆さんは日頃、どのような手段で情報を収集しているのでしょうか?今年はGoogle Readerの終了というこの世の終わりか審判の日かといわんばかりの破壊的なイベントが発生して阿鼻叫喚の地獄を味わった方も多いかもしれません。代替手段はなきにしもあらずなので、「気のせいだった。」とおっしゃる諸兄も多い事でしょう。しかし僕にとってはもう「!!!!!!!」と、声にもならなかった出来事であり、頼みの綱のReederは開発を終了、(その後バージョンアップ)してしまい、自分はただ涙を流して冷たいコンクリートの壁を叩き、泣き崩れる毎日だったのです。そんななか、タイミングよく現れていたGunosy、これぞ救世主とばかりに登録したものの、そもそもの情報ソースが自分の求めていたものとは違ってああお前もかとやはり鉄格子を握りしめて頭を打ち付ける日々を送ることと相成ってしまいました。(今はかなり使い勝手が良いのかも)

こういったことを回避するためには、やはり終了してしまう可能性のある、他人が運営するサービスを利用するよりは自分でサービスそのものを作る必要性がある。このことを Google Reader終了からしばらくして松屋のトマトカレーが教えてくれたのです。トマトカレーよ、お前までいなくなるとは。

はい、要するに自分で情報収集系のツールを作りましょうと。メンテしましょう育てましょうということで、僕が言いたいのはここまでです。

お読みいただき誠に、誠にありがとうございました。


あとは蛇足です!

Google Readerがなくなった背景にはやはりRSSリーダとしての単一機能がやはりよくなかったのだろうと思っています。
今の時代、我々にはTwitterがあり、FBがあり、それほど人気はないようですがGoogle+があるのです。そしてHNがあり、Redditがある。情報ソースは多岐にわたるようになったんですね。

そういったマルチナソース(マルチなソース)を集約し、内容を垂れ流してしまう、そんなアプリこそ皆それぞれが持つべき武器なのです。我らに自由を!

今回はHNだけですけど、様々な情報ソースを集約していくとよいのでしょうね。

決意的ななにか(蛇足の蛇足)

気がつくとアクセスして知らぬうちに時間を浪費してしまう。はてブ恐ろしい子
ということで、自分のための情報ソースを確立するまでもう、はてブは見ない!との強い意志をもって、はてブ封印をしてました。

ちなみにですね、はてブの未来はパーソナライズサービスなんかにあると思ってます。個人的に。ホッテントリって、いわゆるベストセラーのコーナーみたいな、注意を引くけれども別に自分が読まなくてもよいものばかりですし・・・。

ということで僕のマシンではアドレスバーに "b" を打つだけで真っ先に顔を見せるはてブ、悲しいけれどお別れだ!

はてブ見ていたと思ったらいつのまにかHackerNewsを見ていた!何を言ってい(ry

そんな状態を作り出すため、ちょろっと考えたのが b.hatena.ne.jp から news.ycombinator.com へのリダイレクト。
hostsファイルで行けるかなーと思ったのですがやっぱそんなことはなく、proxy通すのもなーと考えて、安直にブラウザの拡張で逃げる!
探してみたけど「うーん」というレベルのものしかなかったのでchrome上にオレオレブラウザ拡張を作ってしのぎましょう。適当にディレクトリを掘って配置するのはファイル二つです。

マニフェストファイル( `manifest.json` )がこっちで、

{
  "name": "Hatebikkuri! Redirector",
  "description": "All your request are belong to us",
  "version": "0.1",
  "manifest_version": 2,
  "background": {
    "scripts": [ "redirect.js" ]
  },
  "permissions":["http://*/*", "https://*/*", "webRequest","webRequestBlocking"]
}

バックグラウンドスクリプト( `redirect.js` )がこっち。

var _redirectUrl = "https://news.ycombinator.com";
var cb = function (details) {
  return { redirectUrl: _redirectUrl};
};
var filter = { urls: ["*://b.hatena.ne.jp/*"] };
var opt_extraInfoSpec = ["blocking"];

chrome.webRequest.onBeforeRequest.addListener(cb, filter, opt_extraInfoSpec);

`chrome://extensions` のページで開発者機能を有効にして、さっきのディレクトリを指定して読み込み、エクステンションを有効にするとあら不思議、はてブ見てるハズなのにいつのまにか HackerNews 見てるじゃないですかー!

こうして都知事が辞職することを発表の三日後くらいに知る事ができる環境が整いました。

と、Chrome 拡張くらい作れるようになっておくといろいろと便利ですというお話でした。
マニフェストを記述したファイルとHTML、JSファイルさえあればよいので結構いろいろ使えますよ。

ちなみに移り気な方はfilterのurls配列についつい見てしまうサイトを追加しておくと捗ります。UIくっつけてきちんとしたものをchromeのwebstoreに登録するのもありかも。
はい、あんまり関係なかったですね。本題に戻ります。

設計

設計と言ってもまぁ、そんな難しくないですね。
やることは以下です。

  • ソースURL取得
  • クローラ
  • 解析
  • HTML生成

と、この四段階に収束します。

ソースURL取得

どこにアクセスして情報ソースのURLを持ってくるかという部分ですね。
RSSであればそれすなわち情報ソースなのであまり複雑ではありません、が、困るのがTwitterやFB、といったソーシャルメディアですね。タイムラインにガガっと。
そこからURLを抜き出します。短縮URLだとカブりがひどいので展開後のを保存。

URLの取得先は、だいたいこんな感じかな?

下処理が必要なのはNews Siteで、URLがたくさん含まれているのでそれらを取得しておきます。
今回はHackerNewsのみを扱ってますが、ほかのは *TODO* に突っ込んでおいてそのうち挑戦しようかと。
HNから取り出したURLは後で使うHTML取得候補のコレクションに入れておきます。あ、ちなみにMongoDB使ってます。

{
    url: String,
    failCount: Int,
    date: Date // fetch した日時
}

適当にこんな感じで。

ちなみにHNのサイトはパースが必要になります。jsdomやcheerioを使うと良いでしょう。ここではjsdomを。
どのようにリンクを抜き出すかはじっくりとHTMLを読み解く事が必要になります。
あ、あとHNをクローリングする時は頻繁にしないようにしましょうね。1時間や2時間に1回くらいでよいかと。

var hnc = require('./lib/hn.js');
var MongoClient = require('mongodb').MongoClient;

function insertUrls(arr, cb) {
  MongoClient.connect('mongodb://127.0.0.1:27017/mynewssite', function(err, db) {
    if(err) throw err;
    var collection = db.collection('news_urls');
    (function insertOne() {
      var record = arr.splice(0, 1)[0];
      console.log('record: '+record);
      var storeObj = {
        "url" : record,
        failCount: 0,
        date: new Date()
      };
      try {
        collection.insert(storeObj, function(err) {
          if (err) {
            cb(err);
            return;
          }
          if (arr.length === 0) {
            db.close();
            cb();
          } else {
            insertOne();
          }
        });
      } catch (e) {
        db.close();
        cb(e);
      }
    })();
  });
}

hnc(function(err, res) {
  if(err) throw err;
  insertUrls(res, function(err){
    if(err) {
      console.log(err);
      return;
    }
    console.log('done');
  });
});

と、このようにして取得しておきます。

おっと、./lib/hn.js はこんな感じ。

var jsdom = require('jsdom'),
    request = require('request'),
    url = require('url'),
    util = require('util');

var HNURL = 'http://news.ycombinator.com';

/**
 * retrieve URL on hn top page
 * Note: please don't retrieve so much, or you'll be blamed.
 *
 */
var hnc = function(cb){
  request(HNURL, function(err, res, body) {
    var self = this;
    self.items = [];
    
    // response filter
    if (body && /^Unknown/.test(body)) {
      return cb(new Error('You are blamed'));
    }
    if ((err && res.statusCode !== 200) || err) {
      return cb(new Error(err));
    }

    // analize the page
    var urls = [];
    var window = jsdom.jsdom(body).createWindow();
    jsdom.jQueryify(window, function(window) {
      var $ = window.jQuery;
      var i = 0;
      $('.title').each(function(){
        if(i%2 === 1) {
          urls.push($("a", this).attr('href'));
        }
        i++;
      });
      cb(null, urls);
    });
  });
};

module.exports = hnc;

短縮URLの展開

ソーシャルメディアは扱わないといっておきながら、そこで流れる短縮URLの展開についてメモ。

これは [request](https://github.com/mikeal/request) モジュールを使うと一発でできます。
`followAllRedirects` というオプションを `true` に設定してリクエストを投げると `response.request.href` に最終的なLocationが入りますのでそれを利用します。

var request = require('request');
var opt =  { method: "HEAD", url: shortUrl, followAllRedirects: true };
function expandUrl(shortUrl, cb) {
  request(opt, function (error, response) {
    if(error) return cb(error);
    cb(null, response.request.href);
  });
}

こんな感じになります。

クローラと解析

node-readabilityが一気にこの作業をしてくれます。
これだけ。なんと簡単なのでしょうか。

var read = require('node-readability');
var fetchUrls = require('./lib/fetchUrls.js');
var MongoClient = require('mongodb').MongoClient;

function summarize(arr, cb){
  MongoClient.connect('mongodb://127.0.0.1:27017/mynewssite', function(err, db) {
    var collection = db.collection('news');
    (function readOne() {
      var record = arr.splice(0,1)[0];
      read(record.url, function(err, article) {
        if(err) {
          console.error('ERR: '+err);
          readOne();
          return;
        }
        var storeObj = {
          url: record.url,
          title: article.title,
          body: article.content,
          date: new Date()
        };
        collection.insert(storeObj, function(err) {
          if(err) throw err;
          console.log('length: '+arr.length);
          if(arr.length === 0) {
            db.close();
            cb();
          } else {
            readOne();
          }
        });
      });
    })();
  });
}

fetchUrls(function(err, res){
  if(err) throw err;
  summarize(res, function(err){
    if(err) throw err;
    console.log('done');
  });
});

あ、fetchUrls.jsのソースも

var MongoClient = require('mongodb').MongoClient;
var util = require('util');

function fetchUrls(cb) {
  MongoClient.connect('mongodb://127.0.0.1:27017/mynewssite', function(err, db) {
    if(err) throw err;
    var collection = db.collection('news_urls');
    collection.find( { failCount: {$gte : 0} }).toArray(function(err, res){
      if(err) {
        db.close();
        cb(err);
        return;
      }
      db.close();
      cb(null, res);
    });
  });
}
module.exports = fetchUrls;

MEMO:
RSS取得について少しメモ。

ニュースサイトの記事とRSSでは性格が異なります。
で、RSSはフィードのコンテントタイプがhtmlの場合があるので、残念ながらそのまま出力というわけにはいきません。

RSSの取得には [feedparser](https://github.com/danmactough/node-feedparser) という便利なモジュールがあるのでそれを使います。

保存項目はタイトル、URL、description、取得日時、ダミーのステータスコード
で、タイトルとdescriptionですが、htmlに一度埋めて、先ほどのものと揃えます。
これはもう、あとの分析でニュースサイトの記事と一緒に解析するためです。省力化。
といいつつRSSは後回し!です。

<html>
<head><title>タイトル</title></head>
<body>
description
</body>
</html>

という形で入れておきます。今回、解析モジュールにnode-readabilityを使って一気に省力化を図っているのですが、今の段階だとURL->サマリーという流れなのでそこを変更する必要があります。

boilerpipeやgooseなんかを使えるようにしても面白いかもしれませんね。

解析部分については巷のWebデータ解析屋さんは何を使っているのでしょうかねー。K-means、EM、Farthest Firstやクラスター分析などなど面白そうなトピックがあるので、解析好きな人は深堀りしてみると良いと思います。

UIとAPI

UIは自由にしてもらうことにして、今回はAPI叩くとJSONを返すようにしておきます。

JSONで最新20件を返しましょうか。

{
    "articles" : [
        {
            "title": "title",
            "url" : "http://link",
            "body" : "some words here",
            "date" : ....
        }, ...
    ]
}

で、expressの簡単ソースがこっちです。

var express = require('express');
var app = express();
var MongoClient = require('mongodb').MongoClient;

app.get('/', function(req, res){
  MongoClient.connect('mongodb://127.0.0.1:27017/mynewssite', function(err, db){
    if(err) {res.send(500, err);}
    var collection = db.collection('news');
    collection.find({}, {_id:0}).sort({date:-1}).limit(20).toArray(function(err, result){
      if(err) {res.send(500, err);}
      var jsonObj = {"articles": result};
      res.json(jsonObj);
      db.close();
    });
  });
});
app.listen(9090);

と、ざっくりとした概要はこんな感じでしょうか。
どの言語でもさらりとかけるような便利なモジュールが揃っていると思いますが、今回はNode.jsで挑戦してみました。
皆さんも自分で情報収集のためのコードを書いてみると良いと思います。

あとがき

最後に `chrome://extensions` を開いて、いまいましい自作リダイレクトプラグインの横にあるゴミ箱ボタンをクリック。これでようやくはてブが見れます!!!