ウェブサイトの更新チェックをするのに、ブラウザを開くのが面倒でスクリプト化したいことがあります。
Node.jsを使って、スクリプト化します。

お題

国立大学法人 電気通信大学から最新の新着情報の日付を取得します。

ブラウザ上での動作

最初にブラウザでの情報取得方法を確認します。
開発コンソールで次のJavaScriptを実行します。

document.querySelector('.newsList').children[0].firstChild.textContent.trim()

"2017年10月 5日"最新の新着情報の日付が取得できます。

解法

色々な実装方法があります。

環境

  • Nodo.js v8.6.0
  • MacBook Pro (Retina, 13-inch, Late 2013)

比較表

最初に簡単な比較を載せます。
ソースコードは後半にあります。

ヘッドレスブラウザ

実装 名前 実行時間(s) 総評
chrome Headles Chrome 1.5〜2.5 今から使うならこれ。開発リソースの安心感
Electoron Nightmare 1.5〜2.5 Headles Chromeと大差はない。無理して乗り換える必要はない
独自実装 PhantomJS 4.3〜4.8 性能と開発リソースに難あり。使い続けるのは厳しい

実行時間はネットワーク環境に依存します。参考情報と考えてください。

HTMLパーサ

種類 名前 パース時間(ms) 総評
HTMLパーサ + 走査API jsdom 150〜170 CLI環境でDOM APIが使えるのが便利
HTMLパーサ + 走査API cheerio 60〜70 CLI環境でjQuery のDOM API を使いたい時に
HTMLパーサ + 走査API libxmljs 8〜9 C言語実装で高速。XPath式で我慢できて、速度を追求したい時に
HTMLパーサ + 走査API xpath(+ parse5) 92〜98 JavaScriptのXPath式実装。PureJSが必要な時に
HTMLパーサ単体 parse5 50〜60 HTMLのASTが欲しい時に。スクレイピング用に単体で使うことはない
HTMLパーサ単体 htmlparser2 39〜44 parse5より少し速い。ドキュメントがフレンドリーでない

パース時間は実行PCの性能、対象のHTMLの内容に依存します。参考情報と考えてください。

その他

種類 名前 実行時間(s) 総評
Webスクレイピングライブラリ Osmosis 0.4〜0.6 速い、軽い、jQuery互換のCSSセレクタが使える。ドキュメントが少ない。
ウェブサービス YQL 1.1〜1.5 クエリの設計が面白い。今使うものではない

実行時間はネットワーク環境に依存します。参考情報と考えてください。

ヘッドレスブラウザ

ヘッドレスブラウザを使ってWebページを開いて解析します。
ブラウザなのでcss、javascript、imageなどの全てのリソースをダウンロードし、レンダリングした後の結果を解析します。

ログインのセッション管理が複雑なサイトや、コンテンツを動的にレンダリングするサイトをスクレイピングする場合に有効です。
今回の例では、遅いだけで意味はありません。

Headless Chrome

Chormeブラウザには画面表示なしで動かす機能があります。
Node.jsから動かす場合にはpuppeteerを使うのが便利です。

pupeteerのAPIはPromise準拠です。
なんでもPromiseを返してきます。async/awaitが使えます。

情報取得のJavaScriptはブラウザで動いたものがそのまま使えます。

Chromeそのものですので、開発チームのリソースが潤沢です。
数年以上の長い期間、安定して使えることが期待できます。

npm init -y
npm i puppeteer
js
const puppeteer = require('puppeteer')

async function getLatestDate(page, url){
  await page.goto(url) // ページへ移動
  // 任意のJavaScriptを実行
  return await page.evaluate(() => document.querySelector('.newsList').children[0].firstChild.textContent.trim())
}

!(async() => {
  try {
    const browser = await puppeteer.launch()
    const page = await browser.newPage()

    const latestDate = await getLatestDate(page, 'http://www.uec.ac.jp/')
    console.log(`最新の新着情報の日付は${latestDate}です。`)

    browser.close()
  } catch(e) {
    console.error(e)
  }
})()

Nightmareと大体同じす。
browserとpageの2種類のインスタンスがあるのが大きな違いです。

node puppeteer.js

1.5〜2.5秒で実行完了します。

Nightmare

Nightmare
現在のNightmareはElectronベースのヘッドレスブラウザです。
Electronで特定のページを開いて、その中で任意のJavaScriptが実行できます。

NightmareのAPIはほぼPromise互換です。
同期APIと非同期APIが入り混じっていて、非同期APIはPromise互換のNightmareインスタンスオブジェクトを返します。async/awaitが使えます。

情報取得のJavaScriptはブラウザで動いたものがそのまま使えます。
使いやすいAPIで人気のあるヘッドレスブラウザです。

npm init -y
npm i nightmare
js
const Nightmare = require('nightmare')

async function getLatestDate(n, url) {
  n.goto(url) // ページへ移動
  // 任意のJavaScriptを実行
  return n.evaluate(() => document.querySelector('.newsList').children[0].firstChild.textContent.trim())
}

!(async() => {
  try {
    const n = new Nightmare({
      show: false // trueにするとブラウザが表示されます。
    })

    const latestDate = await getLatestDate(n, 'http://www.uec.ac.jp/')
    console.log(`最新の新着情報の日付は${latestDate}です。`)

    n.halt()
  } catch (e) {
    console.error(e)
  }
})()
node nightmare.js

1.5〜2.5秒で実行完了します。

PhantomJS

PhantomJS
PhantomJSはWebKitベースのヘッドレスブラウザです。

独自のJavaScriptエンジンで動きます。
Node.jsで動くわけではありません。

ES6は使えないことが多いです。
constは使えましたが、アロー関数とテンプレートリテラルは使えませんでした。
javascript - Does PhantomJS support ES6 for serving angularjs app to crawlers and bots? - Stack Overflow

PhantomJS 2.1.1 only supports about 10% of ECMAScript 6

10%程度しかサポートしていないようです。

特に困るのは、JavaScriptの解釈に失敗した際にエラーを表示せずに実行中のまま固まることです。

あえて今 PhantomJS を使う必要はありません。

js
const page = require('webpage')
  .create()
console.log('start')

page.open('http://www.uec.ac.jp/', function(status) {
  try {
    console.log("Status: " + status)

    const latestDate = page.evaluate(function() {
      return document.querySelector('.newsList')
        .children[0].firstChild.textContent.trim()
    })
    console.log('最新の新着情報の日付は' + latestDate + 'です。')
    phantom.exit();
  } catch (e) {
    console.error(e);
  }
})

ホームページからダウンロードしてきたバイナリファイルを使って実行します。

~/Downloads/phantomjs-2.1.1-macosx/bin/phantomjs phantomjs.js

4.3〜4.8秒で実行完了します。
遅いです。

HTMLパーサ

HTMLパーサを使ってダウンロードしたHTMLを解析します。
HTTPクライアントの機能はないので、requestを併用します。

HTMLファイルのみをダウンロードする(JavaScriptやCSS、imageファイルをダウンロードしない)のと、レンダリング処理をしない分、ヘッドレスブラウザより速いです。

HTMLパーサ + 走査API

jsdom

jsdomは、JavaScriptのHTMLパーサに DOM API を実装したものです。
活発に開発されており、最新のDOM APIに追従しています。

情報取得のJavaScriptはブラウザで動いたものがそのまま使えます。

v10からAPIが大きく変わっています。
古い記事のサンプルコードは動かない可能性が高いです。

npm init -y
npm i request jsdom
js
const request = require('request')
const {
  JSDOM
} = require('jsdom')

request('http://www.uec.ac.jp/', (e, response, body) => {
  if (e) {
    console.error(e)
  }

  try {
    const dom = new JSDOM(body)
    const latestDate = dom.window.document.querySelector('.newsList')
      .children[0].firstChild.textContent.trim()
    console.log(`最新の新着情報の日付は${latestDate}です。`)
  } catch (e) {
    console.error(e)
  }
})
node jsdom.js

0.8~1.4秒で実行完了します。
パースと走査は150〜170msで終わります。

cheerio

cheerioはHTMLパーサに jQuery API のサブセットを実装したものです。
DOM APIはありません。必ずjQueryの関数を使う必要があります。

jQueryに慣れている場合は、学習コストが低く済むでしょう。
jsdomより気持ち速いです。

npm init -y
npm i request cheerio
js
const request = require('request')
const cheerio = require('cheerio')

request('http://www.uec.ac.jp/', (e, response, body) => {
  if (e) {
    console.error(e)
  }

  try {
    const $ = cheerio.load(body)
    const latestDate = $('.newsList').children().first().text().trim()
    console.log(`最新の新着情報の日付は${latestDate}です。`)
  } catch (e) {
    console.error(e)
  }
})
node cheerio.js

0.7~0.9秒で実行完了します。
jsdomより気持ち速いです。
パースと走査は60〜70msで終わります。

libxmljs

libxmljs はC言語で書かれたXMLパーサ libxml のNode.js用バインディングです。
どちらもメンテナンスされ続けています。

今回利用するのはHTMLのパーサとXPath式による走査機能です。

C実装だけあってとにかく速いです。
npm install時にOS固有のバイナリがインストールされます。

npm install libxmljs

> libxmljs@0.18.7 install /Users/shigerunakajima/scraping_test/node_modules/libxmljs
> node-pre-gyp install --fallback-to-build --loglevel http

node-pre-gyp http GET https://github.com/libxmljs/libxmljs/releases/download/v0.18.7/node-v57-darwin-x64.tar.gz
node-pre-gyp http 200 https://github.com/libxmljs/libxmljs/releases/download/v0.18.7/node-v57-darwin-x64.tar.gz
[libxmljs] Success: "/Users/shigerunakajima/scraping_test/node_modules/libxmljs/build/Release/xmljs.node" is installed via remote
npm WARN scraping_test@1.0.0 No description
npm WARN scraping_test@1.0.0 No repository field.

+ libxmljs@0.18.7
added 45 packages in 17.623s

MacOSで開発して、AWS Lambdaにデプロイする(必要なライブラリをzipで固めてアップロードする)場合には一工夫必要かもしれません。

npm init -y
npm install libxmljs
js
const request = require('request')
const libxmljs = require('libxmljs')

request('http://www.uec.ac.jp/', (e, response, body) => {
  if (e) {
    console.error(e)
  }

  try {
    const xmlDoc = libxmljs.parseHtmlString(body)
    const latestDate = xmlDoc.get('(//dl[@class="newsList"])[1]/dt[1]/text()').text().trim()
    console.log(`最新の新着情報の日付は${latestDate}です。`)
  } catch (e) {
    console.error(e)
  }
})
node libxmljs.js

0.3〜0.6秒で実行完了します。
parse5よりさらに速いです。
パースと走査は8〜9msで終わります。

xpath(+ parse5)

xpath はXPath式のNode.js向け実装です。
パーサは含まれていません。

ここではparse5をパーサとして使います。
厳密には javascript - Performant parsing of pages with Node.js and XPath - Stack Overflow を参考にして

  1. parse5でHTMLをパース
  2. xmlserializerを使ってXHTMLに変換
  3. xmldomでDOMオブジェクトに変換
  4. xpathで走査

の手順を踏みます。
Pure JavaScriptでXPath式を使いたいとき向きです。

npm init -y
npm i request parse5 xpath xmlserializer xmldom 
js
const request = require('request')
const xpath = require('xpath')
const parse5 = require('parse5')
const xmlser = require('xmlserializer')
const dom = require('xmldom').DOMParser


request('http://www.uec.ac.jp/', (e, response, body) => {
  if (e) {
    console.error(e)
  }

  try {
    const start = new Date()

    const ast = parse5.parse(body)
    const xhtml = xmlser.serializeToString(ast)
    const doc = new dom().parseFromString(xhtml)
    const select = xpath.useNamespaces({'x': 'http://www.w3.org/1999/xhtml'})
    const nodes = select('(//x:dl[@class="newsList"])[1]/x:dt[1]/text()', doc)

    const latestDate = nodes[0].data.trim()
    console.log(`最新の新着情報の日付は${latestDate}です。`)
    console.log(new Date() - start);
  } catch (e) {
    console.error(e)
  }
})
node xpath.js

0.5〜1.0秒で実行完了します。
parse5より遅く、cherrioより速いです。
パースと走査は92〜98msで終わります。

まあ、当たり前の結果です。

HTMLパーサ単体

parse5

parse5はjsdom内で使われているHTMLパーサーです。

HTMLの抽象構文木を返します。
走査用のAPIはありません。
抽象構文木の走査処理は自分で実装します。

npm init -y
npm i request parse5
js
const request = require('request')
const parse5 = require('parse5')

function digClass(ast, className) {
  if (ast.childNodes) {
    for (const node of ast.childNodes) {
      if (node.attrs && node.attrs.filter(a => a.name === 'class' && a.value === className)
        .length) {
        return node
      }

      const targetNode = digClass(node, className)
      if (targetNode) {
        return targetNode
      }
    }
  }

  return null
}

function childElements(ast) {
  return ast.childNodes && ast.childNodes.filter(n => n.tagName)
}

request('http://www.uec.ac.jp/', (e, response, body) => {
  const start = new Date()
  if (e) {
    console.error(e)
  }

  try {
    const ast = parse5.parse(body)
    const newsList = digClass(ast, 'newsList')
    const latestDate = childElements(newsList)[0].childNodes[0].value.trim()
    console.log(`最新の新着情報の日付は${latestDate}です。`)
    console.log(new Date() - start);
  } catch (e) {
    console.error(e)
  }
})
node parse5.js

0.5〜0.7秒で実行完了します。
cheerioより気持ち速いです。
パースと走査は50〜60msで終わります。

htmlparser2

htmlparser2はcherrioで使われているHTMLパーサーです。
parse5に比べて実行速度が速いのが特徴です。

HTMLの抽象構文木を返します。
走査用のAPIはありません。
抽象構文木の走査処理は自分で実装します。
抽象構文木のプロパティ名はparse5と異なるので注意が必要です。

Usageのサンプルコードを動かしてもdomが得られないので注意が必要です。
Get DOM object of input HTML using Node.js - Stack Overflow

htmlparser2 seems so badly documented to a newcomer :(

とても共感できます。

cherrioの使い方を参考にします。

npm init -y
npm i request htmlparser2
js
const request = require('request')
const htmlparser = require("htmlparser2")

function digClass(dom, className) {
  for (const node of dom) {
    if (node.attribs && node.attribs.class === className) {
      return node
    }

    if (node.children) {
      const targetNode = digClass(node.children, className)
      if (targetNode) {
        return targetNode
      }
    }
  }

  return null
}

function childElements(dom) {
  return dom.children && dom.children.filter(n => n.type === 'tag')
}

request('http://www.uec.ac.jp/', (e, response, body) => {
  if (e) {
    console.error(e)
  }

  try {
    const dom = htmlparser.parseDOM(body)
    const element = digClass(dom, 'newsList')
    if(!element) {
      return
    }

    const latestDate = childElements(element)[0].children[0].data.trim()
    console.log(`最新の新着情報の日付は${latestDate}です。`)
  } catch (e) {
    console.error(e)
  }
})
node htmlparser2.js

0.4〜0.9秒で実行完了します。
パースと走査は39〜44msで終わります。

Webスクレイピングライブラリ

Osmosis

Osmosisは、libxmljs のメンテナンスをしている人が、

を組み合わせて作った、現状考えられる最強のWebスクレイピングライブラリです。
libxmljsは速いのですが、DOM捜査にXPath式しか使えないのが難点です。力技で解決しています。
(やっていることはmechanizeと大体一緒です。nokogiriがRuby向けlibxmlブリッジ+CSSセレクターなので。)

注意点はCSSセレクタが、CSS3の互換ではなく、jQuery互換であることです。
:gt()などが使えます。
DOMのAPIも、少し癖がありWeb標準ではありません。
その割にドキュメントが少ないのが難点です。

使い方は、Using Node-Osmosisを読むのがおすすめです。
Examples and tutorials for Osmosis · Issue #59 · rchipka/node-osmosisを見ると、動画の解説もあるようです。

npm init -y
npm i osmosis
js
const osmosis = require('osmosis')

const start = new Date()
osmosis
  .get('http://www.uec.ac.jp/')
  .find('#news')
  .set({
    latestDate: 'dt'
  })
  .data(({latestDate}) => console.log(`最新の新着情報の日付は${latestDate}です。`))

ソースコードは一番短くてシンプルです。
findでDOMを絞り込み、setで配下のElement内のテキストを取得します。
自動的にtrim()され、不要な空白や改行は削除済みです。
Nodeのテキストを取りたいときは'text()'を使います。
この辺がDOMのAPIとしては、ちょっと独特です。

実際に使ってみると、リストやテーブルの内の値を、(JavaScriptのオブジェクトの)配列として取得できて便利です。

node osmosis.js

0.4〜0.6秒で実行完了します。

ウェブサービス

YQL

YQL - Yahoo Developer Networkはウェブスクレイピング用のウェブサービスです。
特にNode.js向けに限定されたものではありません。

select * from html where url="http://www.shinty.com/news/" and xpath="//div[@class=\'topStories\']/ul/li"

のような、SQLライクなクエリで、参照先WebページのURLと解析用のXPath式を指定すると、解析結果を返すウェブサービスです。

現在は安定したサービス提供がされていません。
一年もすれば正式に利用できなくなると思います。今からあえて利用する必要はありません。

チュートリアルの方法で動かない

標準のhtml tableは現在利用できません。

YQL で html を読み込む方法が変わった(html table is no longer supported.) - arukukaの日記

htmlstringというのを使うようになったらしい

YQL: html table is no longer supported - Stack Overflow

http://query.yahooapis.com/v1/public/yql?q=" + encodeURIComponent(yql) + "&format=json&env=store%3A%2F%2Fdatatables.org%2Falltableswithkeys

現在は、この方法でも動きません。
YQL console上では動きますが、他の環境、例えばcurlでAPIを呼び出すと

<?xml version="1.0" encoding="UTF-8"?>
<error xmlns:yahoo="http://www.yahooapis.com/v1/base.rng" yahoo:lang="en-US"><diagnostics><cache execution-start-time="0" execution-stop-time="0" execution-time="0" method="GET" type="MEMCACHED"><![CDATA[ENV.queryyahooapiscomproductionsg3.store://datatables.org/alltableswithkeys.15a841ff462a38eb6175e73b4dc747ef]]></cache><env>Failed to read from storage: store://datatables.org/alltableswithkeys: Invalid store url: store://datatables.org/alltableswithkeys</env><warning>Invalid environment specified: store://datatables.org/alltableswithkeys</warning></diagnostics><description>No definition found for Table htmlstring</description></error>

このようなエラーが出ます。

YQL の htmlstring テーブル | ADAMの日記 | スラド

うーん、一部のスクリプトは、再度動き始めてるんですよね。
あくまで一部だし、同じスクリプトでも動いたり動かなかったり…。
YQL のエラー詳細を見ると、

Failed to read from storage: store://datatables.org/alltableswithkeys: Invalid store url: store://datatables.org/alltableswithkeys

と出てるので、htmlstring テーブルの定義が読み込めなくて失敗してるっぽい。
その辺の Y! 内部での接続?がコケてるのかなぁ。

詳細は分かりませんが安定しては使えないようです。

動かす方法

Big Sky :: YQLのテーブルを自作する方法

実はこのユーザテーブルは、ネットワーク上にXMLを配置する事が出来る人ならば誰でも作れます。
ユーザテーブルはユーザテーブル群を纏めるenvファイルと、実際のクエリを記述するファイルとで構成され、YQLからenvパラメータを使って参照する事が出来ます。

  1. envパラメータ用のファイルを用意
  2. htmlstring用のxmlファイルを用意

の手順が必要です。

htmlstring用のxmlファイルは https://www.datatables.org/data/htmlstring.xml にあるのですが、これを指定しても

{
  "error": {
    "lang": "en-US",
    "diagnostics": {
      "url": [
        {
          "execution-start-time": "0",
          "execution-stop-time": "1161",
          "execution-time": "1161",
          "content": "https://drive.google.com/uc?authuser=0&id=0B3cqRveqpPAZMi1jVjhMUGtuR0U&export=download"
        },
        {
          "error": "Connect Failure",
          "execution-start-time": "1161",
          "execution-stop-time": "2698",
          "execution-time": "1537",
          "content": "https://www.datatables.org/data/htmlstring.xml"
        },
        {
          "error": "Connect Failure",
          "execution-start-time": "1161",
          "execution-stop-time": "2698",
          "execution-time": "1537",
          "content": "https://www.datatables.org/data/htmlstring.xml"
        }
      ],
      "redirect": {
        "from": "/ec?url=https%3a%2f%2fwww.datatables.org%2fdata%2fhtmlstring.xml&t=1507389614&ttl=60&sig=tZCo6Za.DlXLUvWGoWxjFA--~D",
        "status": "307",
        "content": "https://www.datatables.org/data/htmlstring.xml"
      }
    },
    "description": "No definition found for Table htmlstring"
  }
}

な、エラーが出ます。読み取りに失敗します。
github上にもあるので、こちらを使います。

use 'https://raw.githubusercontent.com/yql/yql-tables/master/data/htmlstring.xml' as htmlstring;

このファイルをインターネット上に公開します。
例えば、Googleドライブに配置して、共有します。

npm init -y
npm i request jsdom
js
const request = require('request')

var yql = `select * from htmlstring where url='http://www.uec.ac.jp' and xpath='(//dl[@class="newsList"])[1]/dt[1]/text()'`
var resturl = `http://query.yahooapis.com/v1/public/yql?q=${encodeURIComponent(yql)}&format=json&env=https%3a%2f%2fdrive%2egoogle%2ecom%2fuc%3fauthuser%3d0%26id%3d0B3cqRveqpPAZMi1jVjhMUGtuR0U%26export%3ddownload`

request(resturl, (e, response, body) => {
  if (e) {
    console.error(e)
    return
  }

  const latestDate = JSON.parse(body).query.results.result.trim()
  console.log(`最新の新着情報の日付は${latestDate}です。`)
})
node yql.js

1.1〜1.5秒で実行完了します。
海の向こうのサーバを経由してから、国内のサーバに聞きに行っている割には、驚きの速さです。

参考

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.