読者です 読者をやめる 読者になる 読者になる

Slackにスレッド表示機能が追加されました

こんにちは、Slack大好きなwakです。Slackにスレッド表示機能が追加されたと聞いてさっそく使ってみた雑感を書きます。

概要

これまでと画面が大きく変わるわけではありません。

  1. スレッドの始まりとなる発言(誰のものでもOK)を選択する
  2. コメント1を追記する(この時点でスレッド化される)と、画面右にスレッド画面が表示される
  3. コメント2, コメント3, ……を書き込んでいく
  4. 元の発言の下に「1 reply」「2 replies」「3 replies」とカウンタが増えていく。書き込んだ人のアイコンも表示される
f:id:nurenezumi:20170120200154p:plain

スレッドに参加する方法

そのスレッドに参加していれば、新しく増えた「All Threads」に通知が来ます。参加するためにはいくつかの方法があります。

  • スレッドを作った人は自動的にそのスレッドに参加する
  • スレッドの開始点となった発言の発言主も自動的にそのスレッドに参加する
  • それ以外の人は、自分で明示的に「follow this thread」をクリックすればスレッドに参加できる
  • スレッドの中でメンションを飛ばされた人も自動的にそのスレッドに参加する
    • ただし @channel@here は使えない

たとえば雑談チャンネルで【今日のお昼はどこで食べたいか】を話したいときは、

  1. 「お昼どこにする? このスレッドで決めましょう」と書き、
  2. その発言を開始点としてスレッドを作って、
  3. その中で参加者にメンションを飛ばす

とすればいいことになります。

ピン留めされたスニペットとの違い

弊社ではゴルフ場様向けに様々なサービスを開発・提供しており、お客様からのお問い合わせも数多くいただきます。そのインシデント管理はSalesforceを活用しているのですが、担当者だけでは解決しなかった場合、別の詳しいメンバーや開発担当等にエスカレーションし、ヘルプを求めます。それを多数のメンバーでスピーディに行うためにSlackを活用しており、サポートチャンネルにその旨をスニペット*1書き込んでピン留めし、簡易的な課題管理のような使い方をしています。

ピン留めされたスニペットや画像は、

  • そのチャンネルに入っている全員が見える
    • 現在未解決のまま残っている問い合わせが一目で分かる*2
  • コメントをスレッド形式で書き込んでいける
    • 進捗や担当・開発者間のやり取りも当初の問い合わせ内容の下にぶら下げて書ける

というメリットがあります。こんな感じになります。

f:id:nurenezumi:20170120195507p:plain

スレッド表示もこんな感じで使えると良いかと思ったのですが、スレッドに参加する・参加させるというステップが必要なこともあり、現在のところはもっと軽い使い方になりそうです。また、チャンネルに参加している人数の多寡によってもありがたみは変わってきそうだと感じました。数百人といった大きなチャンネルでの使い勝手も聞いてみたいところです。

それでは良いチャットライフを。

*1:一言二言で終わるような問い合わせはないため、これは自然な行動です

*2:インシデント管理は別途Salesforce+Remedyforceで行っているため、これはあくまで現状を簡単に確認するためのものです。また言うまでもありませんが、解決したらピン留めは外します。

石器時代みたいなWebアプリ開発をしていたエンジニアがReact.jsに導かれて2016年にやってきた話

はじめに

社内向けのシンプルなWebアプリケーションを作成することになりました。 求められているものはサービスそのものであり、実装方式については全て自分で決めていいわけですが、この際なので2016年のモダンな開発方法で書こうと思い立ちました。そこでReact.jsを採用します。今回の記事はその日記のようなものです。

この記事のまとめ

React.jsを使い始めました。そのためにnpmとBabelを入れました。ついでにgulpとBrowerifyを入れました。快適かつモダンな開発環境を手に入れました。

f:id:nurenezumi:20161220160056j:plain

2016年を代表する猫

まずReact.jsについて

React.jsってなに

Facebookの偉大なエンジニアが作ったフロントエンド用のフレームワークです。特徴として、

  1. DOMを全てJavaScriptで構築・操作する
  2. (基本的には)データの流れが一方通行
  3. コードをReact.js独自の文法「JSX」で記述する

が挙げられます。

DOM

HTMLを書くのはやめます。DOMは全てJavaScript側で構築してHTMLに流し込みます。動的に操作するのももちろんJavaScriptです。したがって、HTMLは

<body>
<div id="app">読み込み中...</div>
<script src="./lib/app.js"></script>
</body>

こんな風に超シンプル(というより空っぽ)になります。React.jsが動き出すと、この<div id="app">読み込み中...</div>が丸ごとJavaScript側で生成したDOMに置き換わるわけです。

一方通行

jQueryを使ったDOM操作でWebアプリケーションを作るとき、たとえば画面の右側(例:アイテムの絞り込み条件)と左側(例:アイテムの一覧表示)で不整合が起きるようなバグに苦しむことがあります。これは画面の要素のあちこちが独自に状態を抱え込み、しかもその要素が複雑に絡み合って情報を受け渡しするため、その管理を正しく行うのが難しくなるからです。

これに対してReact.jsでは、

  1. 画面に表示すべき全てのデータを1個のオブジェクトにまとめてしまう
  2. そのオブジェクトを受け取って画面表示する(=DOMに変換する)処理を書く

という基本原則があります。画面を書き換えたい場合は、

  1. オブジェクトの値を更新する
  2. 全ての画面要素を一括で描画し直す

で済ませてしまいます。このルールにより、画面表示と内部データは常に一貫性を持っていることが保証されます。jQueryでこんなことをやっていたら重くてまともに操作できなくなりそうですが、React.jsはまず画面更新後のDOMを内部的に算出し、発生した差分のみに対して実際のDOM操作を行うというテクニックを採用しており(これはVirtual DOMと呼ばれています)、処理は高速に行われます。差分がなければDOM操作は発生しません。

JSX

ここからが本題です。

まず、React.jsのコードはJSXという文法で記述します。これはJavaScriptを拡張した独自の文法で、JavaScriptに似ていますがJavaScriptではありません(明らかに文法エラーになります)。簡単な例を示します。

function App() { return <span>hello world!</span>; }
ReactDOM.render(<App />, document.getElementById('app'));

JavaScriptのコード中にHTMLのタグが出現しています。これは、

という処理です。コンポーネントはいくらでも作ることができます。React.jsでは、Webアプリケーションを構成する要素をこのコンポーネントに分解し、それらを組み立てることでDOMを構築していきます。

JSX→JavaScriptの変換をしたい

JSXはFacebookの偉大なエンジニアが勝手に定義した言語ですので、ブラウザは解釈できません。したがって、JSX→素のJavaScriptへの変換を誰かが行う必要があります。

1つめの方法は手動で変換をかけるものです。オンラインの変換ツールがありますので、ここにJSXをコピペすれば通常のJavaScriptに変換してもらえます。実際にやることはないでしょうが、一度このリンクを開いておいてください。

2つめの方法は毎回動的に変換をかけるものです。HTML側で

<script src="https://fb.me/JSXTransformer-0.13.3.js"></script>
<script type="text/jsx" src="app.js"></script>

と書いておくと(type="text/jsx"に注目しましょう)、JSXで書かれたapp.jsをその場でJSXTransformerがJavaScriptに変換してくれます。これはお手軽なのですが、当然ながらパフォーマンス上の問題が生じますし、JSXTransformerは既にdeprecatedになっています

最後の方法は、何らかのツールでトランスパイル*1をかける方法です。

ここでちょっとnpmについて

.NETにNuGetがあるように、PHPPEARやComposerがあるように、またRubyにgemがあるように、JavaScript*2にもnpm (Node.js Package Manager)というパッケージマネージャーがあります。多数の人々により様々なパッケージが提供されているわけですが、そのパッケージをダウンロード・インストールしてくれたり、その際に依存関係を解決してくれたり、また新しいコマンドをインストールしてくれたりする優れものです。

インストール

npmはsudo apt install npmで簡単に入ります。すみませんがAmazon LinuxなどFedora系では入れるのが地味に面倒くさいので自分で調べてください。

プロジェクト作成

npmでは、まず最初にプロジェクトを作成する必要があります。空のディレクトリに対して

npm init

を実行すると、プロジェクト名などの基本情報を入力するように要求され*3、これが終わるとこのディレクトリはプロジェクトとして管理対象になります。管理対象と言っても、単に管理用ファイルpackage.jsonが新規作成されるだけです。

パッケージ取得・インストール

これができたら次のコマンドでパッケージを取得・インストールすることができます。

npm intall --save <パッケージ名>
npm intall --save-dev <パッケージ名>

ここで--saveと書いているのは、package.jsonの中に「このプロジェクトが依存するパッケージ」としてインストールしたパッケージを記録しておくことを示します。開発時にのみ必要なパッケージをインストールする際は--save-devオプションを指定します。他の人は後からpackage.jsonを見ればそのプロジェクトのために必要なパッケージが分かりますし、package.jsonだけ手元にあれば、npm installでその中に記載されたパッケージを一括でダウンロード・インストールすることができます。

さてインストールを行うと、プロジェクトのディレクトリ直下にnode_modulesディレクトリが作成され、ダウンロードしたファイル(多くの場合はJavaScriptのコード)が入ります。これらのコードは、

var hoge = require("MODULE_NAME");

などの文法で参照することができます。これについては後述します。

また、インストールと同時にnode_modules/.bin/以下に実行可能ファイルが置かれるものもあります。これはプロジェクトごとにインストールされるものですが、プロジェクトによらず、OS全体で(グローバルに)利用したいこともあるでしょう。後述のgulpなどがそれにあたります。この場合は、

sudo npm install --global <パッケージ名>

でインストールができます。OS全体に影響が及ぶのでsudoが必要なことがあります。

Babel導入

さて、上の方で試しに利用したオンライン変換ツールの名前はBabelとありました。そもそもBabelってなんでしょう?

Babelの名前は、(おそらく)聖書のバベルの塔の記述、「全ての地は、同じ言葉と同じ言語を用いていた」から来ています。JavaScript*4にはES5, ES2015(ES6), ES2017(ES7)といくつかのバージョンがあり、また正式採用前ながらも開発者コミュニティによく使われている文法もあり、さらにはJSXのような方言も存在します。そのサポート状況はブラウザによって異なります。先進的なバージョンの文法で書かれたJavaScriptはどれ一つとしてブラウザが対応していないことだって考えられますし、現時点ではJSXを理解できるブラウザは存在しません(これから先もないでしょう)。このようなJavaScriptを古いバージョンのJavaScriptに変換し、どのようなブラウザでも解釈できるようにしてくれるのがBabelです。「同じ言葉と同じ言語」が実現するわけですね。

Babelはオンラインで使うこともできますが、普通はコマンドとして使いたいでしょう。そこでnpmを使います。このような流れで実行します。

  1. 空のプロジェクトを作る
  2. このプロジェクトにnpmでBabelをインストールする(babelコマンドも入る)
  3. このプロジェクトにnpmでBabelのreactプリセット(プラグインのようなもの)をインストールする
  4. 準備完了

順に実行します。まず空のディレクトリを作り、そこに空のプロジェクトを作成します。

mkdir helloworld
cd helloworld
npm init

次にBabelとBabelのプリセットをインストールします。

npm install --save-dev babel-cli babel-preset-react

変換が終わってしまえば稼働時には必要ないので--save-devを指定しています。これで準備は完了です。次のコマンドでJSXのファイルを読み込み、JavaScriptに変換して標準出力に吐き出します。

function App() { return <span>hello world!</span>; }
ReactDOM.render(<App />, document.getElementById('app'));
neko@ubuntu:/workdir$ node_modules/.bin/babel --presets react app.js
function App() {
  return React.createElement(
    'span',
    null,
    'hello world!'
  );
}
ReactDOM.render(React.createElement(App, null), document.getElementById('app'));

先ほどオンラインで試したものと同じコードが出力されました。

ES2015? ES2016???

上の方で出てきた「ES2015」「ES2016」について説明します。

まず、JavaScriptECMAScriptという規格に沿った原語です。ECMAScriptには、

  • ECMAScript 5th Edition (ES5)
    • IE6とかのいにしえの時代はこれ準拠のJavaScriptが使われていた
  • ECMAScript 2015 (ES2015)
    • ES6とも呼ばれる
  • ECMAScript 2016 (ES2016)
    • ES7とも呼ばれる

というバージョンがあり、更新されるたびに便利な(あるいは複雑な)機能が追加されていっています。テンプレートリテラル(文字列の組み立て)などはもうES5の時代には戻れません。

var name = "neko";

// ES5
console.log("my name is " + name + ".");

// ES2015
console.log(`my name is ${name}.`);

主要なブラウザが搭載しているJavaScriptエンジンはES2015に対応していますが、ES2016は新しすぎるためまったくと言っていいほど未対応です。

gulp導入

さて話を戻して、ここではgulpというツールを投入します。

gulpとは「ビルドシステム」で、あらかじめ定義したタスクをまとめてコマンド一発で実行してくれるツールです。たとえばJSXをJavaScriptに変換するだけではなく、ついでに圧縮とかもしたいですよね? それもコマンド一発でやって欲しいですよね? さらにはファイルの更新を監視し、それらの処理を自動で済ませたりしてほしいですよね? gulpがやってくれます。

試しに使ってみる

まず、グローバルでgulp-cliをインストールします。どこのディレクトリで実行しても構いません。

sudo npm install --global gulp-cli

これでどこからでもgulpコマンドが実行できるようになりました。次に、プロジェクトのディレクトリに移動してgulpとgulpが利用するパッケージをインストールします。ここではJavaScriptのコードを圧縮するgulp-minifyを入れてみます。

npm install --save-dev gulp gulp-minify

gulpは「あらかじめ定義したタスク」を自動で行うものだと言いました。この定義はgulpfile.jsというファイルで行います。プロジェクトディレクトリの直下にgulpfile.jsを作成し、以下のように書きます。

var gulp = require('gulp');
var minify = require('gulp-minify');

gulp.task("neko", function() {
  gulp.src("src/*.js")
    .pipe(minify())
    .pipe(gulp.dest("lib"));
});

gulp.task("default", ["neko"]);

ここではnekoという名前のタスクを定義していて、その内容はsrcディレクトリ以下の*.jsを対象にminify()して、結果をlibディレクトリに出力するというものになっています。また、タスク名が指定されなかった場合のdefaultタスクの内容としてはnekoを使ってね、ということも書いてあります。

試しにsrc/app.jsとしてこんなJavaScriptを書いて、

// tama-san is kawaii
var name = "tama";
console.log("hello, my name is " + name + " desu.");

コマンドラインgulpとだけ実行すると(明示的にgulp nekoと実行しても構いません)、libディレクトリ(存在しなければ自動的に作成されます)にオリジナルのapp.js、圧縮済みのapp-min.jsが出力されます。中身を見てみます。

var name="tama";console.log("hello, my name is "+name+".");

確かにコメントや空白が削除されて圧縮されています。

gulp + Babel

これでgulpが何者かは分かりました。gulpからBabelを使えるようにしましょう。まずgulpが利用するパッケージをインストールします。

npm install --save-dev gulp babel-cli babelify browserify vinyl-source-stream babel-preset-react babel-preset-es2015

見知らぬ名前がたくさん出てきました。

  • gulp : gulp本体
  • babel-cli : Babel CLI
  • babelify : Babel
  • browserify : (後述)
  • vinyl-source-stream : gulpが利用する
  • babel-preset-react : BabelのReact.js用プリセット
  • babel-preset-es2015 : BabelのES2015用プリセット

分かってしまえばどうと言うことはありませんね!*5

gulpfile.jsの内容はこうなります。

var gulp = require("gulp");
var babelify = require("babelify");
var browserify = require("browserify");
var source = require("vinyl-source-stream");

gulp.task("neko", function() {
  browserify({entries: "src/app.js"})
    .transform(babelify)
    .bundle()
    .on("error", function (err) { console.log("Error : " + err.message); })
    .pipe(source("app.js")) 
    .pipe(gulp.dest("./lib/"))
});

gulp.task("default", ["neko"]);

gulp.task("watch", function() {
  gulp.watch("src/*.js", ["neko"]);
});

さらにプロジェクトディレクトリの直下に.babelrcというファイルを新規作成します。これはBabelの動作内容を決める設定ファイルです。ReactのJSXとES2015を使いたいのでこのように書きます。

{"presets": ["react", "es2015"]}

これで下準備が全て整いました。srcディレクトリにapp.jsを配置します。

const App = props => <span>hello world!!!!</span>; // ES2015の新文法
ReactDOM.render(<App />, document.getElementById('app'));

これでgulpを実行すると、libディレクトリに変換されたJavaScriptが出力されます。これを次のHTMLで読み込めば動作します。

<!doctype html>
<html>
<head>
<script src="https://unpkg.com/react@15/dist/react.js"></script>
<script src="https://unpkg.com/react-dom@15/dist/react-dom.js"></script>
</head>
<body>
  <div id="app">読み込み中...</div>
  <script src="./lib/app.js"></script>
</body>
</html>

ファイル監視

gulpとだけ実行すると、gulpはタスクを1回だけ行って終了します。gulp watchとすると、gulpはファイルの更新を監視し、更新されたらその場その場でタスクを繰り返し実行してくれます。つまり、もう手動でコマンドを実行する必要はなくなるのです。

Browserify

次に、上の項で唐突に出てきたBrowserifyの解説をします。

古き良き時代

ここでjQuery UIのことを思い出してみましょう。ドキュメントを読むと「jQuery UIはjQueryに依存している」と書いてあります。したがって、HTMLに

<script src="https://code.jquery.com/jquery-3.1.1.slim.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>

のように書かないといけないのでした。このような依存関係は人間がドキュメントを読んだり書いたりして管理しないといけません。また、jQueryグローバル変数$を利用するため、同じ変数を利用するライブラリとは同時に利用できません*6。これも利用者が気を配ってやる必要があります。

Node.jsではどうやっているか

このような牧歌的な時代は終わりました。現代のJavaScriptではこんなのどかなことを言っていてはいけません。

サーバーサイドJavaScript実行環境であるNode.jsには他モジュールのインポート機能があります。たとえばAWS APIを使いたいのであれば、まずnpmを使ってaws-sdkパッケージをインストールします。

npm install --save aws-sdk

プログラムコード中でaws-sdkをインポートするにはrequire()文を使います。

var AWS = require("aws-sdk"); // aws-sdkという名前のモジュールを読み込む
var ec2 = new AWS.EC2();
ec2.startInstances(...); // EC2インスタンスを起動

ブラウザでも同じことがしたいと誰もが思います。しかし(現在の)ブラウザに許されるのは<script>タグでHTTPリクエストを発生させてソースコードを読み込むことだけ。このような仕事はブラウザの領分を超えています。

そこでBrowserify

Browserifyは、npmと組み合わせてこのインポート構文をブラウザ用JavaScriptにも持ち込むためのツールです。このように使います。

  1. npm install --save <パッケージ名>で必要なパッケージをインストールする
  2. JavaScriptのコードの中にrequire()文を書く
  3. Browserifyを通すと、require()が消滅し、代わりに参照したパッケージが埋め込まれる

この結果、ユーザーは参照しているライブラリ(パッケージ)が全て結合された1つの大きな.jsファイルを得ます。名前空間の汚染がないような工夫も行われています。

例1

JavaScriptで日次を任意の書式(たとえばYYYY/mm/dd HH:mmとか)で出力したいというとき、整数のフォーマットが面倒でいつも頭を悩ませていました。従来はそのためのjQueryプラグインなどに頼っていたわけです。

我々はもうjQueryと手を切ると決めたので、ここでnpmパッケージを探すとdateformatというものがあります。便利そうなので使わせてもらいましょう。インストールは簡単です。

npm install --save dateformat

あとはrequire()で読み込むだけです。

var dateFormat = require("dateformat"); // これ!

var timestamp = dateFormat(new Date(), "yyyy/mm/dd HH:MM");
console.log(timestamp); // "2016/12/21 12:34"

これをgulpでビルドすると、require()部分が消えてdateformatが取り込まれ、単体で動作するJavaScriptソースコードが出てきます。

例2

最初の方の例では、react.js, react-dom.jsを直接CDNから読み込んでいました。これを統合してみましょう。

まず、npmでreact, react-domを追加します。

npm install --save react react-dom

次にapp.jsを修正します(上2行を追加します)。

var React = require("react");
var ReactDOM = require("react-dom");

const App = props => <span>hello world!!!!</span>; // ES6の文法
ReactDOM.render(<App />, document.getElementById('app'));

そしてgulpでこのapp.jsをビルドすると、生成されたlib/app.jsはライブラリを取り込んで700KBになりました*7。これならもうスクリプトは1個で済みます。

<!doctype html>
<html>
<head>
</head>
<body>
  <div id="app">読み込み中...</div>
  <script src="./lib/app.js"></script>
</body>
</html>

終わりに

ここまで準備してしまえば、その他のライブラリを導入するのも、別のトランスパイラを利用するのもぐっと障壁が低くなります。特にJavaScriptの開発環境は異様なほど流行廃りが速いのですが、なるべく便利なものをどんどん取り入れて快適に開発を進めていきたいものです。

*1:コンパイルのようなものですが、コンパイルのようにバイナリを吐くわけではないのでこう呼ばれることがあります

*2:正確にはNode.js

*3:全てEnter空打ちでOKですが、プロジェクト名に大文字は含められないため、ディレクトリ名に大文字が入っている場合だけは手で入力する必要があります

*4:正しくはECMAScript

*5:こういうところでは気になってもとりあえず先に進み、しかし後から戻ってきてちゃんと理解するのが惑わずにいるコツです

*6:近年ではあまり名前を見なくなりましたが、prototype.jsが該当します

*7:圧縮すればもっと縮みます

LINEのMessaging API WebhookをAPI Gatewayで受ける

こんにちは、AWS担当のwakです。

最近LINE botがMessaging APIとしてリニューアルしたとのことで、同僚が面白がって触っていたので説明してもらいました。聞くところによると、LINEのbotを友達登録したりメッセージを送ったりアンケートに答えたりすると、その内容がリアルタイムで自分のサーバーに送られてくるWebhookがあるそうです。

f:id:nurenezumi:20161102104059p:plain

そしてLINEのMessaging API WebhookはHTTPSのエンドポイントを要求します。このためだけにサーバーを立てたり証明書を取ったりするのも面倒ですし、AWSAPI Gateway+Lambda(Node.js)で受けることにしました。今回はその話を書きます。

f:id:nurenezumi:20161102113329j:plain

概要

話はシンプルです。LINEからのメッセージをAPI Gatewayで受け、署名(後述)をチェックし、それ以外のものをどこか(SNS, DynamoDB, アプリケーションサーバーなど)に投げます。こうすることにより、仮に一気にアクセスが集中した場合でも応答ができなくなることは防げますし、API Gateway側で流量制御もできます。また、アプリケーションが直接インターネットに露出する必要がなくなるので気を遣う点が少なくなります。

準備

Lambda

Node.jsで書きます。コードはこの記事の末尾にあります。先に作成しておいてください。

API Gateway

いつも通りResources→Create Resource→Create MethodとたどってPOSTのエンドポイントを用意します。ここでLambdaを指定するのですが、"Use Lambda Proxy integration"のチェックをONにしてください。

f:id:nurenezumi:20161027171436p:plain

Use Lambda Proxy integrationがONなので、その他の細かい設定は不要です。API Gatewayがアクセスを受け付けると、HTTPヘッダ、ボディ等がまとめてLambdaに渡されます。

問題なければデプロイしてエンドポイントを確定させておきます。

LINE Messaging API Webhook

LINE Business Centerにログインし、「アカウントリスト」タブからMessaging APIを探して設定画面を開きます。ここにある「Channel Secret」を取得し、Lambdaのコード中にコピペして更新しておきます*1

f:id:nurenezumi:20161027172503p:plain

同じ画面の下の方にWebhookのエンドポイントを登録する場所があります。

f:id:nurenezumi:20161027173056p:plain

ここにAPI Gatewayのエンドポイントを書いて「VERIFY」ボタンを押してみましょう。ここまでの設定が全て正しく行っていれば、API Gateway経由でLambdaが実行され、ログが出力されるはずです。

正しくログが出力されればこれで終わりです。あとはbotを友達登録したりチャットをしたりしてください。

メッセージの検証

さて、ちょっと別のことを考えます。

LINE(メッセージを送信する側)の立場から見た場合、WebhookのエンドポイントはHTTPS限定ですから、登録したエンドポイントにメッセージを送信すれば確実に私に届くことは保証されています。なりすましに騙されて違う相手にメッセージが届いてしまうこと、送信経路でメッセージが盗聴・改竄を受ける可能性を気にする必要はありません*2。ところが私(メッセージを受ける側)から見た場合は話が変わります。たとえば誰かがLINEのサーバーを装って本物と同じフォーマットで偽のメッセージを送ってくるかもしれません。こういった攻撃は正しく見破って破棄する必要があります。

HMACってなに

このように、メッセージの改竄・捏造を検出する手段の一つがHMAC: Hash-based Message Authentication Codeです。HMACでは、事前に共有した秘密鍵とメッセージを混ぜ合わせてハッシュを取り、MAC値と呼ばれる値を算出します。送信側はこのMAC値をメッセージに添えて送ります。受信側はメッセージの内容と秘密鍵*3から同じ手順でMAC値を算出し、添えられたMAC値と比較して、一致すればこのメッセージは本物であると判断します。なぜならば、メッセージに対応した正しいMAC値が分かるのは秘密鍵を知っている2人だけだからです。秘密鍵が分からないのにメッセージをでっち上げようとしてもうまくいきません。

なお、HMACの利点は秘密鍵そのものをメッセージに含める必要がないことで、つまりSSLを通さず平文でメッセージをやり取りすることも可能です(覗き見は避けられませんが、改竄は不可能だということになります)。

算出方法

API Reference に従ってメッセージのMAC値(をBASE64エンコードした文字列)を算出します*4

var crypto = require('crypto');
var hmacsha256 = crypto.createHmac("sha256", "deadbeefdeadbeefdeadbeefdeadbeef"); // 第2引数はChannel Secret

var body = "..."; // 受信したbody部
var bodyStream = new stream.Readable();
bodyStream.push(body);
bodyStream.push(null);

bodyStream.on("data", chunk => {
    hmacsha256.update(chunk);
});
bodyStream.on("end", () => {
    var digest = hmacsha256.digest("base64");
    console.log(`digest: ${digest}`);
});

このdigestの値と、X-Line-Signatureヘッダの値とを比較すれば検証は終わりです。

var signature = (event.headers || {})["X-Line-Signature"];

Lambdaのコード

aws-sdk, crypto, streamはいずれも標準の環境に含まれているため、特に複雑なことをする必要はありません。このコードではログを出力するだけですが、もちろんなんでも好きなことができます。

'use strict';

var AWS = require("aws-sdk");
var lambda = new AWS.Lambda({region: "ap-northeast-1"});
var crypto = require('crypto');
var stream = require("stream");

exports.handler = function(event, context) {
    console.log(event);

    var signature = (event.headers || {})["X-Line-Signature"];
    var body = event.body || "";
    var bodyStream = new stream.Readable();
    bodyStream.push(body);
    bodyStream.push(null);
    
    var hmacsha256 = crypto.createHmac("sha256", "YOUR-CHANNEL-SECRET"); // TODO: 管理画面からChannel Secretを取得して書き換える
    bodyStream.on('data', chunk => {
        hmacsha256.update(chunk);
    });
    
    bodyStream.on('end', () => {
        var digest = hmacsha256.digest("base64");
        if (digest === signature)
        {
            console.log("署名検証結果OK");
            // TODO: 何かする
        }
        else 
        {
            console.log("署名検証結果NG");
            // TODO: 何かする
        }
        var response = {
            statusCode: 200,
            headers: {
                "x-custom-header" : "my custom header value"
            },
            body: '{"result":"owari"}'
        };
        context.succeed(response);
    });
};

*1:もちろん本当はDynamoDBとかに入れておきます

*2:そのためのSSLなので

*3:上述の通り、秘密鍵は事前にこっそり共有します。メッセージには含まれていません

*4:もちろん本来はバイトストリームから直接MAC値を算出すべきですが、API Gatewayでは文字列しか扱えません。LINEがメッセージを送信する際の文字エンコーディングも、Streamのデフォルトの文字エンコーディングUTF-8であることを前提に処理を書いています

ASP.NET Webフォームプロジェクトでwebフォームの内容をhtmlファイルにしてダウンロードさせる

ASP.NET C#

井上です。
いまだASP.NET Webフォームを用いているシステムも多々あると思います。
弊社もWebフォームのシステムがあり、そこで普段やらないことをやりましたのでそれをまとめておきます。
こんな処理を書くことはそうそう無いのですが何かの役に立つかもしれません。

環境

.NET Framework 4.5.1
ASP.NET Web Form
C#

目的

他システム(Linux/Apache)にアップロードするためのhtmlファイルをWebフォームを元に作成する。

方法

  1. HTML作成テンプレートとなるWebフォームを作成
  2. HTML作成用テンプレートを呼び出すWebフォームを作成
  3. 2で1に渡すパラメータの取得、session経由での引き渡しを行い、1を実行する
  4. 結果を加工しダウンロードさせる

Web Formを使うことによりListView等にバインドできたり、編集が比較的楽というメリットがあります。コードも見やすいです。
当然ですが、Web Formの結果であるため不要なタグを端折る必要があります。

コード

テンプレート

<html xmlns="http://www.w3.org/1999/xhtml">
<!-- headタグは出力対象 -->
<head>  
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>    
    <title>hogehoge</title>
</head>
<body>
    <form id="form1" runat="server">
        <!-- body start -->
        <div class="templatestart"/>

        <!-- ここにいつも通りコードを書く。ー -->

        <div class="templateend"/>
        <!-- body end -->
    </form>
</body>
</html>

テンプレートのコードビハインド

using System;

public partial class hogehoge
{
    protected void Page_Load(object sender, EventArgs e)
    {        
        // セッションからパラメータ取得しバインド
        var data = (HogeHoge)Session["hogehoge"];

        this.DataBind();

        Session.Remove("HogeHoge");   
    }
}

HTML作成するWebフォームのコードビハインド

using System;
using System.Text;
using System.Web;

protected void CreateHtml_Click(object sender, EventArgs e)
{
    string url = "/template/hogehoge.aspx";

    // セッションに引き渡しパラメータを入れる
    Session.Add("hogehoge", new HogeHoge());

    // 実行・出力
    using (System.IO.StringWriter writer = new System.IO.StringWriter())
    {
        StringBuilder outputhtml = new StringBuilder();

        // テンプレートファイルを実行する
        HttpContext.Current.Server.Execute(url, writer, false);
        string html = writer.ToString().Replace(System.Environment.NewLine, "");

        // headタグ取得
        Regex regex = new Regex("<head>.*</head>");
        Match match = regex.Match(html);
        string headerhtml = match.Value;

        // 出力タグ取得
        regex = new Regex("<div class=\\\"templatestart\\\"/>(?<body>.*?)<div class=\\\"templateend\\\"/>");
        match = regex.Match(html);
        string bodyhtml = match.Groups["body"].Value;

        // html組み立て
        outputhtml.Append("<html>");
        outputhtml.Append(headerhtml);
        outputhtml.Append("<body>");
        outputhtml.Append(bodyhtml);
        outputhtml.Append("</body></html>");

        // ダウンロード
        Response.ClearContent();
        Response.ContentType = "text/html";
        Response.AddHeader("Content-Disposition", "attachment;filename=" + url.Substring(url.LastIndexOf('/') + 1).Replace(".aspx", ".html"));
        Response.Write(outputhtml.ToString());
        Response.End();
        Response.Flush();
    }
}

HttpContext.Current.Server.Executeで実行結果を受け取れますので、不要なタグを端折った後、ダウンロードさせています。

ASP.NET Webフォームプロジェクトでオートコンプリートのドロップダウンを実現する

ASP.NET C#

井上です。
ASP.NET Webフォームのドロップダウンコントロールでオートコンプリートをやろうというお話です。 ちょっとと面倒だったのでまとめておきます。

環境

.NET Framework 4.5.1
ASP.NET Web Form
C#

参考

jqueryui.com

やりたいこと

オートコンプリート化したいDropDownListコントロールを含むページで、お手軽にオートコンプリート化させる

コード

オートコンプリート関数の作成(jquery.autocomplete.js)

;(function ($) {
    $.fn.autocompleteExtend = function () {
        return this.each(function() {
            $.widget("custom.combobox", {
                _create: function () {
                    this.wrapper = $("<span>")
                      .addClass("custom-combobox")
                      .insertAfter(this.element);

                    this.element.hide();
                    this._createAutocomplete();
                    this._createShowAllButton();
                },

                _createAutocomplete: function () {
                    var selected = this.element.children(":selected"),
                      value = selected.val() ? selected.text() : "";

                    this.input = $("<input>")
                      .appendTo(this.wrapper)
                      .val(value)
                      .attr("title", "")
                      .attr("onKeydown", "return InvalidInput(event)") // enterキーの挙動を打ち消す
                      .attr("style", "float:left;") // ブラウザ差異対応
                      .addClass("custom-combobox-input ui-widget ui-widget-content ui-state-default ui-corner-left autocomplete")
                      .autocomplete({
                          delay: 0,
                          minLength: 0,
                          source: $.proxy(this, "_source")
                      })
                      .tooltip({
                          tooltipClass: "ui-state-highlight"
                      });

                    this._on(this.input, {
                        autocompleteselect: function (event, ui) {
                            ui.item.option.selected = true;
                            this._trigger("select", event, {
                                item: ui.item.option
                            });
                        },

                        autocompletechange: "_removeIfInvalid"
                    });
                },

                _createShowAllButton: function () {
                    var input = this.input,
                      wasOpen = false;

                    $("<a>")
                      .attr("tabIndex", -1)
                      .attr("title", "")
                      .tooltip()
                      .appendTo(this.wrapper)
                      .button({
                          icons: {
                              primary: "ui-icon-triangle-1-s"
                          },
                          text: false
                      })
                      .removeClass("ui-corner-all")
                      .addClass("custom-combobox-toggle ui-corner-right autocomplete")
                      .mousedown(function () {
                          wasOpen = input.autocomplete("widget").is(":visible");
                      })
                      .click(function () {
                          input.focus();

                          // Close if already visible
                          if (wasOpen) {
                              return;
                          }

                          // Pass empty string as value to search for, displaying all results
                          input.autocomplete("search", "");
                      });
                },

                _source: function (request, response) {
                    var matcher = new RegExp($.ui.autocomplete.escapeRegex(request.term), "i");
                    response(this.element.children("option").map(function () {
                        var text = $(this).text();
                        if (this.value && (!request.term || matcher.test(text)))
                            return {
                                label: text,
                                value: text,
                                option: this
                            };
                    }));
                },

                _removeIfInvalid: function (event, ui) {

                    // Selected an item, nothing to do
                    if (ui.item) {
                        // 選択値でポストバックさせる
                        setTimeout('__doPostBack(\' + $(this).attr("name") + \' ,\'\')', 0);
                        return;
                    }

                    // Search for a match (case-insensitive)
                    var value = this.input.val(),
                      valueLowerCase = value.toLowerCase(),
                      valid = false;
                    this.element.children("option").each(function () {
                        if ($(this).text().toLowerCase() === valueLowerCase) {
                            this.selected = valid = true;
                            return false;
                        }
                    });

                    // Found a match, nothing to do
                    if (valid) {

                        return;
                    }

                    // Remove invalid value
                    this.input
                      .val("")
                      .attr("title", "")
                      .tooltip("open");
                    this.element.val("");
                    this._delay(function () {
                        this.input.tooltip("close").attr("title", "");
                    }, 2500);
                    this.input.autocomplete("instance").term = "";
                },

                _destroy: function () {
                    this.wrapper.remove();
                    this.element.show();
                }
            });

            // 挙動設定
            $('#' + $(this).attr("id") ).combobox();

            // ドロップダウンのスクロール指定
            $('.ui-autocomplete').css('overflow-y', 'auto');
            $('.ui-autocomplete').css('overflow-x', 'hidden');
            $('.ui-autocomplete').css('height', '200px');
        });
    }
})(jQuery);

$(function () {
    $(".autocomplete").autocompleteExtend();
});

参考先のコードを元に作成。
入力値/選択値がリスト内の要素と完全一致するときのみポストバックさせます。
また動的に生成されるドロップダウンのスクロール指定等を行っています。
最後の処理はautocompleteクラスを持つ要素に対してautocompleteExtendを呼び出すためのものです。

呼び出し元コード(sample.aspx)

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="sample.aspx.cs" Inherits="WebApplication1.sample" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title></title>
    <script src="Scripts/jquery-1.12.4.min.js"></script>
    <script src="Scripts/jquery-ui-1.12.0.min.js"></script>
    <script src="Scripts/jquery.autocomplete.js"></script>
    <link rel="stylesheet" href="Content/themes/base/jquery-ui.min.css" />
    <style>
    .custom-combobox {
     position: relative;
     display: inline-block;
    }
    .custom-combobox-toggle {
     position: absolute;
     top: 0;
     bottom: 0;
        margin-left: -1px;
     padding: 0;
    }
    .custom-combobox-input {
     margin: 0;
     padding: 5px 10px;
    }
    </style>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <asp:DropDownList ID="HogeHogeList" runat="server" OnSelectedIndexChanged="HogeHogeList_SelectedIndexChanged" AutoPostBack="true" CssClass="autocomplete"></asp:DropDownList>   
    </div>
    </form>
</body>
</html>

DropDownListコントロールautocompleteクラスを指定しているのみです。

呼び出し元コード(sample.aspx.cs)

using System;
using System.Collections.Generic;

namespace WebApplication1
{
    public partial class sample : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            if (!IsPostBack)
            {
                var list = new Dictionary<string, string> { 
                { "", "0" }, 
                { "Aゴルフ場", "1" }, 
                { "Bカントリー倶楽部", "2" },
                { "Cゴルフコース", "3" },
                { "Dカントリークラブ", "4" },
                { "Eゴルフリゾート", "5" } 
            };
                this.HogeHogeList.DataSource = list;
                this.HogeHogeList.DataTextField = "Key";
                this.HogeHogeList.DataValueField = "Value";
                this.HogeHogeList.DataBind();
            }
        }

        protected void HogeHogeList_SelectedIndexChanged(object sender, EventArgs e)
        {
            // 選択時の処理
        }
    }
}

実行すると

f:id:ihisa:20160721154007p:plain

入力すると一致する候補がきっちり表示されます。

f:id:ihisa:20160721153935p:plain

リストから選択すればSelectedIndexChangedイベントが発生します。

grepコマンドとPowerShellのsls (Select-String)の比較

PowerShell

こんにちは、PowerShellが好きなwakです。以前書いたこちらの記事をリライトします。

tech.sanwasystem.com

はじめに

Windowsにはgrepコマンドがないとはよく言われることですが、Windowsに標準で備わっているPowerShellにはgrepよりも高性能な検索コマンドレット、Select-Stringが実装されています。この記事はgrepSelect-Stringとを比較し、慣れない人でもすぐに使えるようにすることを目的としています。また、「そもそもこのSelect-Stringコマンドをどうやって実行するの?」といったPowerShellの(超)基本的な使い方は本稿の末尾に記載してあります。

grepとの比較表

grep Select-String
大文字・小文字の区別 -i -CaseSensitive *1
正規表現 -E -SimpleMatch *2
マッチしない行を検索 -v -NotMatch
前後表示 -A 《行数》
-B 《行数》
-Context 《行数》
文字コード指定 不可*3 -Encoding 《文字エンコーディング名》
ディレクトリを
再帰的に検索
-r (dir -recurse 《ファイル名パターン》)
ファイル名のみ出力 -l
-L
(後述)

以下、順に解説していきます。

1件のファイルから検索をする場合

grepで1件のファイルから指定したフレーズを検索する場合はこのように実行しますが、

grep pattern filename.txt

PowerShellでこちらに相当する処理はこうなります。

Select-String "pattern" filename.txt

Select-Stringにはslsという省略形が用意されています。エイリアスですのでどちらを使ってもまったく同じ結果が得られます。

sls "pattern" filename.txt

本稿では以降もSelect-Stringと表記しますが、もちろん実際に使うときにはslsで構いません。

-i : 大文字・小文字を区別したい/区別したくない

デフォルトでは大文字・小文字は区別されません。区別したい場合は-CaseSensitiveオプションを追加します。

# "Neko"にはマッチするが"neko"にはマッチしない
Select-String "Neko" filename.txt -CaseSensitive

-e : 正規表現を使いたい/使いたくない

パターンはデフォルトで正規表現とみなされます。正規表現として扱ってほしくない場合は-SimpleMatchオプションを追加します。

# "SELECT * FROM"にマッチ。-CaseSensitiveも指定しているので小文字ならマッチしない
Select-String "SELECT * FROM" filename.txt -SimpleMatch -CaseSensitive

-v : パターンにマッチしない行を探したい

-NotMatchオプションを使います。

# 「A」「a」「B」「b」「C」「c」のいずれも含まない行にマッチ
Select-String "[A-C]" filename.txt -NotMatch

-A / -B : パターンにマッチした前後の行も出力したい

-Contextオプションで行数を指定します。残念ながら前後別々に行数を指定することはできません。

# "function", "Function"などがある行と、その上下3行ずつ(計7行)を検索
Select-String "function" HogeClass.cs -Context 3

文字エンコーディングを指定したい

検索対象のファイルがShift-JISだと文字エンコーディングの自動判定ができないので、明示的に指定してあげなければいけません。これは-Encodingオプションを使います。

Select-String "猫" filename.txt -Encoding oem

-Encodinggの後ろにスペース1個)と入力した後でTABキーを押せば指定可能な文字エンコーディングが順に表示されます。oemがShift-JISだと覚えておけば事足りるでしょう(UTF-8がデフォルトです)。

複数のファイルから検索する場合

ここまでは1件のファイルから検索を行うものでした。次は複数件のファイルを対象として検索を行う方法です。

ワイルドカードを使いたい

まず普通にワイルドカードが使えます。

Select-String "pattern" *.txt

特定の複数のファイルを指定して検索したい

ワイルドカードではなく、ファイル名を書き並べたいならこうなります。カッコとダブルクオートが少々見づらいのですが我慢してください。

Select-String "pattern" ("z:\log\filename1.txt", "c:\data\text\filename2.txt")

ディレクトリを再帰的にたどって検索したい

次のどちらでも好きなものを選んでください。c:\data以下にある*.cs全てを検索対象としています。

Select-String "pattern" (dir -recurse c:\data\*.cs)
dir -recurse c:\data\*.cs | Select-String "pattern"

Dir -recurse c:\data\*.csは、c:\data以下から*.csを全部探すという意味になります(1回このコマンドだけを実行してみるといいです)。ディレクトリ名を省略したらカレントディレクトリになります。

特定の拡張子のファイルは除外したい

-Excludeで除外できます。

# カレントディレクトリ以下の全てのファイルから検索、ただし*.exeと*.binは除く
Select-String "pattern" (dir -recurse *.* -Exclude *.exe, *.bin)
dir -recurse *.* -Exclude *.exe, *.bin | Select-String "pattern"

検索対象のファイルを別のテキストファイルから与えたい

1行に1件ファイル名(フルパス)が書いてあるテキストファイルfilelist.txtがあったとして、そこから検索をする場合です。

Get-Content filelist.txt -Encoding UTF8 | % { Select-String "pattern" $_ }

なお、カレントディレクトリ以下の全てのファイル名(フルパス)をファイルに書き出すにはこのように実行します。ワイルドカードの部分は必要に応じて*.txtなどと書き換えてください。

dir -Recurse -File *.* | % { $_.FullName } | Out-File filelist.txt -Encoding UTF8

出力の書式や出力先を変える

標準の出力の書式では、ファイル名・行数・行の内容(行全体)が出力されます。なぜかというと、この結果1行1行はそれぞれMicrosoft.PowerShell.Commands.MatchInfoクラスのインスタンスであり、このToString()メソッドがそのような書式の文字列を返すようになっているからです。

MatchInfo Class (Microsoft.PowerShell.Commands)

このオブジェクトはFileName, Lineといったプロパティを持っていますから、欲しいものを好きなように並べて結果を出力することができます。

結果だけを画面に出力したい

ファイル名はいらない場合です。

# 末尾に「| Select Line」と書き加える
Select-String "pattern" filename | Select Line
Select-String "pattern" (dir -recurse *.* -Exclude *.exe, *.bin) | Select Line

以下では例は1行ずつしか示しませんが、どのような検索を行ったかにかかわらず、検索を行ったコマンドの末尾に「| ~」と書き加える形でOKです。

結果だけを画面に出力したい

ファイル名はいらない場合です。

Select-String "pattern" filename | Select Line

少し書式は面倒ですが、こちらの方を使うと色々と融通がききます。

Select-String "pattern" filename | % { $_.Line }

マッチした部分だけを出力したい

Select-String "pattern" filename | % { $_.Matches.Value }

このMatchesは.NETのSystem.Text.RegularExpressions.Matchクラスのインスタンスです。パターンに()を使っていればGroupsプロパティでさらに部分文字列を取り出せたりします。*4

Select-String "^(\d{4})-(\d{2})-(\d{2})" (dir -Recurse *.*) | % { $g = $_.Matches.Groups; $g.Groups[1].Value }

ファイル名(フルパス)、行数、行の内容をタブ区切りで出力したい

Select-String "pattern" filename | % { [string]::Format("{0}`t{1}`t{2}", $_.Path, $_.LineNumber, $_.Line) }

.NETを使っている人にはお馴染みのSystem.String.Format()を使ってみました。`tは他言語の\tと同じタブ文字を意味します。PowerShellらしく書くなら次のどちらかになりそうです。

# 末尾に「|」以降を書き加える
Select-String "pattern" filename | % { "{0}`t{1}`t{2}" -f $_.Path, $_.LineNumber, $_.Line }
Select-String "pattern" filename | % { ( $_.Path, $_.LineNumber, $_.Line) -join "`t" }

色々と試行錯誤したい

検索結果をまず先に変数に格納しておけば毎回検索する手間が省けるので話が早くなります。

$searchresult = Select-String "pattern" filename

これでまず検索結果が変数に入ります(画面には何も出ません)。

$searchresult

とだけ入力して実行すれば検索結果が出力されますし、

$searchresult | Select Line
$searchresult | % { "{0}`t{1}`t{2}" -f $_.Path, $_.LineNumber, $_.Line }

のように色々と試すこともできます。

結果をファイルに書き出す

検索結果は画面に出力するだけではなくファイルに書き出すこともできます。これについてはgrepと同じようにリダイレクトで吐き出すのが一番簡単でしょう。

Select-String "pattern" filename | % { ... } > result.txt
$searchresult | Select Line > result.txt

文字エンコーディングUTF-16になります。これが気に食わない人は、末尾に> ファイル名と書き加えるのではなく、さらにパイプをつなげてOut-Fileコマンドレットに渡します。

Select-String "pattern" filename | % { ... } | Out-File result.txt -Encoding UTF8
$searchresult | Select Line | Out-File result.txt -Encoding UTF8

ここで出力されるファイルはBOMありUTF-8という微妙なフォーマットになるのですが、これは諦めてください(少しコードを書けば解決するのですが)。

PowerShellの基本

起動

まず、何はなくともPowerShellを立ち上げましょう。

なんでもいいです。

補完

dir, Select-Stringといったコマンドレットによって入力可能なオプションはあらかじめ決まっています。-まで入力してTABキーを連打すると、指定可能なオプションが列挙されます。たとえば-CaseSensitiveであれば、-cまで入力してTABキーを押せば一発で補完される、といった具合です。

また、ファイル名・ディレクトリ名も同じように補完が効きます。

大文字・小文字

コマンドレット・オプションの大文字・小文字は区別されません。Select-StringSELECT-STRINGselect-stringと書いても問題ありません。

\のエスケープっていらないの?

PowerShellでは\文字は特別な意味を持ちません。AB.と同じ普通の文字です。したがって\のエスケープは必要ありません。特に正規表現を書くときにはこれが楽です。

そのかわり、たとえば改行文字はn`、タブ文字は\t`と書くことになっています。バッククオートそのものを検索したいときはバッククオートを2つ連ねて書いてください。

*1:デフォルトで区別なし

*2:デフォルトで正規表現

*3:grep単体では不可能。nkfとか使う

*4:"?"の書式でのグループ名には対応していないようでした

SGMLReader + XPathでスクレイピングする

C#

こんにちは、今日もWebを見ながら生きているwakです。表題通りのことをやりました。

f:id:nurenezumi:20160620161016j:plain

はじめに

目的

任意のHTMLをSystem.Xml.Linq.XDocumentに格納してXPathで目的の要素を探してスクレイピングすることです。

HTMLはXMLじゃない

HTMLはXMLではありません。したがって、

<link href="mystyle.css" rel="stylesheet" type="text/css">

のように「終了タグがない」ことが定められていたり、条件付きで省略が認められていたりします。こういった文書はXMLとしては不正なので、System.Xml.Linq.XDocumentにパースさせてもエラーになってしまいます。

SgmlReader

そこで登場するのがSgmlReaderで、これは

SgmlReader is a versatile C# .NET library written by Chris Lovett for parsing HTML/SGML files using the XmlReader API.

とあるように、XMLとしては不正なHTMLを食べてXMLにしてくれるライブラリです。

github.com

同じような目的のライブラリとしてHtml Agility Packがあり、こちらの方がずっとメジャーなようなのですが、読み込んだドキュメントが独自のクラスになることに抵抗があって今回は利用しませんでした。

SgmlReaderの使い方

導入

NuGetで提供されているので、NuGetパッケージマネージャーコンソールから

Install-Package SgmlReader

と入力すれば導入できます。開発はGitHubで進められており、こちらの方が微妙に新しい*1ので、こちらを使っても良いと思います。まず上記のリポジトリを丸ごと落としてきてビルドし、出てきたSgmlReaderDll.dllを参照に追加して終わりです(あるいはSgmlReader以下の*.csを2件直接プロジェクトに突っ込むだけでも構いません)。またSgmlReaderはApache License 2.0でライセンスされていますので、もしアプリケーションを再配布する際はその旨明記しておきます。

読み込み

文字列→XDocument

次のようなコードを書くだけでOKです。

public static XDocument Parse(string content)
{
    using (var reader = new StringReader(content))
    using (var sgmlReader = new SgmlReader { DocType = "HTML", CaseFolding = CaseFolding.ToLower, IgnoreDtd = true, InputStream = reader })
    {
        return XDocument.Load(sgmlReader);
    }
}

ファイルでもWeb上のコンテンツでもStreamのことが多いと思いますし、それならば本当はTextReaderから直接流し込めるのですが、前もってHTMLの手直しをする必要に迫られたときのために一度stringにしています。

XPathEvaluate()で型を指定する

後でSystem.Xml.XPath.Extensions.XPathEvaluate()を多用しますが、このメソッドはXPathならなんでも渡せるので(つまり戻り値はXElement, XText(), XAttributeのどれでもあり得るので)戻り値はobjectになります。戻り値の型があらかじめ分かっているときにはこんな拡張メソッドを追加しておくと簡単になります。

public static IEnumerable<T> XPathEvaluate<T>(this XElement element, string xpath)
{
    return ((IEnumerable<object>)element.XPathEvaluate(xpath)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XElement element, string xpath, IXmlNamespaceResolver resolver)
{
    return ((IEnumerable<object>)element.XPathEvaluate(xpath, resolver)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XDocument document, string xpath)
{
    return ((IEnumerable<object>)document.XPathEvaluate(xpath)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XDocument document, string xpath, IXmlNamespaceResolver resolver)
{
    return ((IEnumerable<object>)document.XPathEvaluate(xpath, resolver)).Cast<T>();
}

ドキュメント全体から要素を検索する

System.Xml.XPath.Extensions.XPathSelectElement()またはXPathSelectElements()を使います。たとえばページ内の全てのa要素(そのうちhref属性があるもの)を取得したければこのようになります。

XDocument document = Parser.Parse(content);
IEnumerable<XElement> links = document.XPathSelectElements("//a[@href]");

マッチする要素のうち先頭1件だけで良ければXPathSelectElement()(単数形)を使います。0件ならnullが戻ります。

XElement firstLink = document.XPathSelectElement("//a[@href]");

ドキュメント全体から何かを検索する

要素(XElement)以外を検索するときはXPathEvaluate()を使います。このメソッドの戻り値の型はobjectで使い勝手が悪いので、さっきの拡張メソッドを使うと綺麗に書けます。たとえばページ内の全てのa要素のhref属性(つまりXAttributeです)の値を取り出したければこうなります。

IEnumerable<string> linkUris = document.XPathEvaluate<XAttribute>("//a[@href]/@href").Select(x => x.Value);

「ページ全体からh3タグの中身(テキスト、すなわちXText)をリストアップする。ただし最初の1個は不要」ならどうでしょうか。

IEnumerable<string> midashi1 = document.XPathEvaluate<XText>("/descendant::h3[position()>1]/text()").Select(x => x.Value);
IEnumerable<string> midashi2 = document.XPathEvaluate<XText>("//h3/text()").Skip(1).Select(x => x.Value);

どちらでも同じ結果が得られますが、XPath初心者の私にとってはLINQを使った後者の方が見やすく感じられます。なお//h3[position()>1]/text()ではダメです(各々の親要素の中で最初の1個が除外されてしまいます)。

特定の要素の下から何かを検索する

これも同じくXElementに対してXPathEvaluate()を呼びます。たとえば次のようなHTMLがあったとして、

<div id="top">
  <span class="a">A1</span>
  <span class="b">B1</span>
  <span class="b">B2</span>
</div>
<div id="middle">
  <span class="a">A2</span>
  <span class="b">B3</span>
</div>

まず1個目のdiv要素は

XElement top = document.XPathSelectElement("//div[@id='top']");

で取れます。この下からspan.bを検索したければ、

// 直下から検索
IEnumerable<XElement> spanB1 = top.XPathEvaluate<XElement>("span[@class='b']");
// 子要素または孫要素から検索
IEnumerable<XElement> spanB2 = top.XPathEvaluate<XElement>(".//span[@class='b']");

となるわけです。「#topの子要素または孫要素のspan.b」のつもりで//span[@class='b']と書いてしまうとドキュメント全体からの検索になるので気をつけましょう。

Chromeのコンソールと注意点

Chrome$x()でお手軽XPath

XPathは複雑になってくると暗号みたいになるのでなるべくお手軽にテストができた方が便利です。Chromeでは特にアドオンなどを必要とせず、開いているドキュメントに対してコンソールから$x("任意のXPath")でXPathのテストができる機能があります。

Command Line API Reference  |  Web  |  Google Developers

$x("任意のXPath")だけでなく、$$("任意のCSSセレクタ")というショートカットもあります。活用しましょう。

Chromeが勝手に要素を補う場合

この機能はXPathで要素を検索してスクレイピングに役立てる際に非常に便利なのですが、HTMLの内容によってはハマることがあります。たとえば次のようなHTMLがあったとします。

<!-- tbodyがない -->
<table id="neko">
  <tr><th>たま</th><td>3歳</td><td>黒と白</td></tr>
  <tr><th>ぶち</th><td>2歳</td><td>茶色と白</td></tr>
  <tr><th>しろ</th><td>8歳</td><td></td></tr>
</table>

これをChromeに読み込ませると、Chromeは「tableの直下にはtheadtbodyしか来ない」ことを知っているため、自動的にtbodyを補ってDOMを構築します。

f:id:nurenezumi:20160617110248p:plain

一方、SgmlReaderはそこまで細かい処理は行いません。したがって、要素はtable > tr > thのように並ぶことになり、tbodyがあることを前提にXPathを書くと失敗します。

SgmlReaderが補完に失敗する場合

別にこれはSgmlReaderが悪いわけではないのですが、HTMLに間違いがあるとさらに面倒なことになります。たとえばこんなHTMLを考えます。

<form action="post">
  <!-- inputタグ閉じ忘れ -->
  <input type="text" placeholder="猫の名前">
  <input type="text" placeholder="年齢">
  <input type="text" placeholder="色">
  <span>間違えないように入力してね!</span>
  <input type="submit" value="送信">
</form>

一昔前の古いサイトではありがちな間違いで、ブラウザはこの程度なら(とはいえ膨大なルールが集積された結果のはずですが)意図通り終了タグを補って解釈してくれます。

f:id:nurenezumi:20160617115351p:plain

しかしSgmlReaderはこれをこのように解釈してしまい、

<form action="post">
  <input type="text" placeholder="猫の名前">
    <input type="text" placeholder="年齢">
      <input type="text" placeholder="色">
        <span>間違えないように入力してね!</span>
        <input type="submit" value="送信"></input>
      </input>
    </input>
  </input>
</form>

//form/input[@type='text']というXPathを評価すると、最初の「猫の名前」1件がヒットし、他のinput要素は一見取得できていないように見えてしまいます(実際には順次入れ子になって取得できています)。これに気付かないと散々悩むことになります。

これを防ぐには、前もってHTMLを修正しておくか、XPathを工夫してロバストなクエリを書くように心がけるかしかありません。スクレイピングに付きものの悩みではあります。

おわりに

一頃流行した「セマンティック・ウェブ」という言葉はあまり聞かなくなってしまいましたが、Webページになるべく正しいHTMLで正しい情報を記載しておくことはその第一歩であり、より良いインターネットに繋がる道だと考えています。その間をXPathLINQで上手に乗り越えてゆきましょう。

*1:1.8.11と1.8.12(2016/6/27時点)