WinRTベースのWindows Phone内でスクレイピング – Windows Phone Advent Calendar 2014

この投稿は、Windows Phone Advent Calendar 2014 の 8日目です。

WinRTベースのWindows Phone内で、HTMLのスクレイピングを試みるネタです。とはいえ、実はこれは約1年前の年始早々に取り組んだネタの続きでもあります。

Windows PhoneもWinRTベースの環境と、続々と出荷されるWindows Phone 8ベースの端末(何故か国内では発売されていない。「何故か」w)のおかげで、すっかりWinRTも浸透してきたようです。

pronama_wp_wp_800

(写真に登場するLumia 1520とプロ生ちゃんはフィクションであり、この記事とは無関係ですw)

SgmlReader for Portable Class Library

以前に作ったスクレイピングのライブラリは、
「SgmlReader」と呼ばれるものをPortable Class Library化したものです。このライブラリは、「SGML」をパースしてXmlReader化するものですが、HTMLのDTDが添付されており、要するに「普通のHTMLパーサー」として使えるところがミソです。

移植した際に、Profile1ベース(つまり、最初期のPCLで、環境的に何でもOK)という縛りをつけたので、ストアアプリ前までの環境では、あらゆる環境で使用可能です。

pcl1

この柔軟性はかなり美味しくて、他に依存しているパッケージも存在しないため、どのようなプロジェクト(Windows Phoneに限らず)にでも安心して追加する事が出来ます。

が、WinRT環境では、PCLの設定が排他的になっており、この例のように「全部入り」にしようとしても出来ません。

そのため、WinRT用に別のPCLプロファイルを組み合わせたライブラリを追加し、SgmlReaderをバージョンアップさせようと思っていたのですが… ズルズルと今の今まで放置プレイ(困っていなかったと言うのが大きい)。

Windows Phone Advent Calendarがあるとの事だったので、ようやく重い腰を上げて取り組むことにしました。


夜明け前(前のバージョン)

Portable Class Libraryへの移植は「最大公約数」的な対応が必要です。SgmlReaderの最初の移植では、WebClientクラスを使用して直接HTTP通信を行う部分がネックとなりました。

今なら「Microsoft HTTP client libraries」という、これまたPCLで非同期な優秀なパッケージがあり、これを使えばかなりの広範囲で移植が可能なパッケージを作ることも出来たのですが、元々SgmlReader自体がシンセティック(パーサーなので、理論上、計算しかしない)なので、何にも依存しないパッケージに仕上げたかったのです。

取り組んだこと:

  • WebClientを使っている箇所を削除
  • エラーが発生する部分を修正
  • NuGetパッケージ化

ストラテジとして、WebClientの代わりにデリゲートを導入し、HTTP通信に関わる部分をDI的に挿入できるようにしようと考えていました。ところが、SgmlReaderは「SGML」であるために、DTDの外部参照を自力で解決しようとします。そこで再帰的にWebClientを使ってダウンロードを試みようとするため、この部分まで含めた対応が必要でした。

結局、初期のストリーム(ストリームと言いながらTextReaderなんですが)を与える以外にも、任意のURLからのダウンロードを可能にするデリゲートを挿入可能とし、WebClientへの依存を排除しました。これが前のバージョンです。

また、NuGetパッケージ化は「NuGet Packager」というAddinを使って行いました。NuGetのパッケージ化は面倒です。今のところ、サクッとやる方法が無く、nuspecファイルを手動で編集する必要があります。NuGet Packagerはビルド部分を自動化出来ますが、結局NuGetの構造を知らないと書けないと思います。そして、NuGet Packagerはどうも、その、MSBuildの流儀から外れているのか、ゴミファイルが散乱したりして、落ち着いて保存できないのもモヤモヤしていました。


本番

sgmlreaderstoreapp1png

そして、今回のバージョンアップです。まず、ストアアプリ向けのPCLプロジェクトを追加します。前のバージョン(Profile1)と併存させるため、わざとVisual Studio 2012で作ります。

pcl2

PCLの設定は、グレードを上げて、こんな感じに。

ソースコードは、前のプロジェクト内のソースファイルに対して「リンクで追加」を使って、参照として追加しておきます。

SgmlReaderの最初のバージョンでは、HtmlのDTD参照をフックして、組み込みリソースとしてありました。DTDの指定がHtmlとなっている場合は、Assembly.GetManifestResourceStream()を使って、これを読み取る事で、リソースメンテナンスフリーにしていたのです。

ところが、WinRT環境では、Type.Assemblyが未定義です。これはどうしたものかと思っていたら、Resharperがヒントをくれました。Typeクラスへの拡張メソッドとして「IntrospectionExtensions.GetTypeInfo()」があります。名前が意味深ですね。とにかくこれで、TypeInfoクラス経由でAssemblyクラスのインスタンスが手に入り、GetManifestResourceStream()を使う事が出来ました。

(Resharperが無かったら短時間で探せなかったかも。今見たら、TypeInfoには面白そうなアレコレがありそうだ…)

あとは、WinRT版との切り分けに、「#if NETFX_CORE」を使うだけです。こんな感じ:

#if NETFX_CORE
using (var stm = type.GetTypeInfo().Assembly.GetManifestResourceStream(name))
#else
using (var stm = type.Assembly.GetManifestResourceStream(name))
#endif

さて、ビルド出来たらNuGetへの対応です。先ほど紹介したNuGet Packagerは、どうも完成度がいまいちだったので、別の何かが無いかどうかを探してみました。何しろあれから1年です。

で、見つけたのが以下の二つ:

  • NuGet Package Project – MSの人が(個人的に)作っている拡張機能で評判は良さそうですが、いかんせんVS2013のみの対応。
  • NuBuild project system – VS2010~2013まで対応していて、素朴ではあるものの、妙な挙動が無い。

今回は2012でやっているので、どちらにしてもNuBuild project systemを選択です。

nubuild

これはNuBuildプロジェクトのダイアログですが、「Add Binaries To SubFolder」だけTrueに変えておきます。今回のパッケージには、Profile1のPCLとWinRT向けのPCLの2つのバージョンを入れ込むため、サブフォルダに配置するように指示します。あとはデフォルトのままです。

nuspecファイルはビジュアルに編集するエディタは無く、単純にxmlとして編集します。もちろん、ひな形は提供されるので、埋めていく感じです。

<?xml version="1.0" encoding="utf-16"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
	<metadata>
		<!-- Required metadata -->
		<id>CenterCLR.SgmlReader</id>
		<version>2014.12.7.3</version>
		<title>SgmlReader for Portable Class Library</title>
		<summary>SgmlReader for Portable Class Library. SgmlReader most popular usage the "HTML" parser. (It's scraper!!)</summary>
		<authors>Kouji Matsui</authors>
		<description>SgmlReader for Portable Class Library.
SgmlReader is "SGML" markup language parser, and derived from System.Xml.XmlReader in .NET CLR.
But, most popular usage the "HTML" parser. (It's scraper!!)

/* Use SgmlReader in Html parse mode. */
XDocument document = SgmlReader.Parse(stream);

Done!</description>
		<releaseNotes>2014.12.7.3:
 Add 1 line parse method.
2014.12.7.2:
 Direct handling the Stream class.
 Initial parameter is set of Html parse mode.
2014.12.7.1:
 Namespace changed "CenterCLR.Sgml".
 More easy usage, HTML parse is default mode.
 Native store app library included.
1.8.11.2014:
 Initial release.</releaseNotes>
		<projectUrl>https://github.com/kekyo/CenterCLR.SgmlReader.git</projectUrl>
		<iconUrl>https://raw.githubusercontent.com/kekyo/CenterCLR.SgmlReader/master/CenterCLR.SgmlReader.100.png</iconUrl>
		<requireLicenseAcceptance>false</requireLicenseAcceptance>
		<licenseUrl>http://opensource.org/licenses/Apache-2.0</licenseUrl>
		<copyright>Copyright (c) 2002, Microsoft Corporation; Copyright (c) 2007-2013, MindTouch; Copyright (c) 2014, Kouji Matsui</copyright>
		<tags>SgmlReader Parser Portable HtmlReader Html Scraping</tags>
		<!-- Optional metadata
		<owners></owners>
		<dependencies>
		</dependencies>
		<references></references>
		-->
	</metadata>
</package>

nubuild1

そして、NuBuildプロジェクトのReferencesにプロジェクト参照を追加します。あとはビルドすれば、Bin配下に*.nupkgが出来ているはずです。プロジェクト参照すると、きちんと依存関係も設定されます。

上記のnuspecにはfilesタグが含まれません。通常、nuspecファイルを手で作る場合は、パッケージに含める配布物をfilesタグに含めますが、そこはこのプロジェクト参照で自動的にNuBuildがやってくれます。しかも、いつもながら面倒なプラットフォーム指定文字列(「portable-net403+sl40+win+wp+Xbox40」のような)も、自動的に判定してくれます!これは本当に嬉しい。

(プラットフォーム指定文字列の一覧は、何故か物凄く探しにくいです。備忘録としてリンクを張っておきます

ビルドして生成されたnupkgをNuGetにアップロードします。アップロードの方法は、NuGetでユーザー登録するかMSアカウントでサインインしてアップロードするだけ。nuspecに書かれた定義を元に解析を行うので、特にこれと言って追加の作業はありません。

一点、アイコン参照(nuspec内のIconUrl)は、正しいURLを示していないとアイコンが表示されません。今回はGitHubで公開する事もあり、GitHub上にアイコンファイル(100x100px)を上げておいて、アイコンファイルへのRaw参照URLを指定するようにしました。


実際に使ってみる

wpad

これでNuGet出来るようになったので、使ってみましょう。パッケージIDは「CenterCLR.SgmlReader」です。

やっていることは、プロ生で登壇した時のスクレイピングネタと殆ど同じです。但し、プロ生Advent Calendar進捗ダメで書いた通り、そのままではスクレイピング出来ないので (;´Д`)、代わりにいつものネタに使う「郵便番号データ」のページを使っています。

/// <summary>
/// 指定されたURLのHTMLを読み取って解析します。
/// </summary>
/// <param name="url">URL</param>
/// <returns>コンテンツ群のURL</returns>
private static async Task<IReadOnlyList<KeyValuePair<Uri, string>>> LoadFromAsync(Uri url)
{
	using (var client = new HttpClient())
	{
		using (var stream = await client.GetStreamAsync(url).ConfigureAwait(false))
		{
			// SgmlReaderを使う
			var sgmlReader = new SgmlReader(stream);

			var document = XDocument.Load(sgmlReader);

			// 郵便番号データダウンロードのサイトをスクレイピングする
			// ターゲットは、html/body/div[id=wrap-inner]/div[id=main-box]/div[class=pad]/table/tbody/tr/td/a
			// にあるhrefとなる。
			// パースとトラバースまでワーカースレッドで実行しておく。
			return
				(from html in document.Elements("html")
					from body in html.Elements("body")
					from divWrapOuter in body.Elements("div")
					let wrapOuter = GetAttribute(divWrapOuter, "id")
					where wrapOuter == "wrap-outer"
					from divWrapInner in divWrapOuter.Elements("div")
					let wrapInner = GetAttribute(divWrapInner, "id")
					where wrapInner == "wrap-inner"
					from divMainBox in divWrapInner.Elements("div")
					let mainBox = GetAttribute(divMainBox, "id")
					where mainBox == "main-box"
					from divPad in divMainBox.Elements("div")
					let pad = GetAttribute(divPad, "class")
					where pad == "pad"
					from table in divPad.Elements("table")
					from tbody in table.Elements("tbody")
					from tr in tbody.Elements("tr")
					from td in tr.Elements("td")
					from a in td.Elements("a")
					let href = GetAttribute(a, "href")
					where href.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) == true
					let zipUrl = ParseUrl(url, href)
					where zipUrl != null
					let text = a.Value
					where string.IsNullOrWhiteSpace(text) == false
					select new KeyValuePair<Uri, string>(zipUrl, text)).
				ToList();
		}
	}
}

wpadresult

ZIPファイルへのリンクをトラバースするだけで、中身は展開していません。スクレイピングが出来る事は確認できます。


気になっていた点の改良

身も蓋もない事を言います:

「どうせ、今更SGMLなんて使わないんですw」

と言う訳で、SGMLサポートを削除… まではしませんが、Htmlのパースをもっとサクッと出来るようにしておきました。

何しろ、これ以上簡単には出来ません。1行で行けます。

// ストリームを与えてHtmlをパースし、XDocumentを返す。
XDocument document = SgmlReader.Parse(stream);

では、みなさん、楽しんでください!


SgmlReader for Portable Class Library

GitHub:Repository: CenterCLR.SgmlReader
NuGet:Packaged ID:CenterCLR.SgmlReader

AdventCalendar.StoreAppDemo1(デモ用Windows Phoneプロジェクト)

GitHub:Repository: CenterCLR.AdventCalendar.StoreAppDemo1

つぎは、icchuさん、お願いします!