この投稿は、Windows Phone Advent Calendar 2014 の 8日目です。
WinRTベースのWindows Phone内で、HTMLのスクレイピングを試みるネタです。とはいえ、実はこれは約1年前の年始早々に取り組んだネタの続きでもあります。
Windows PhoneもWinRTベースの環境と、続々と出荷されるWindows Phone 8ベースの端末(何故か国内では発売されていない。「何故か」w)のおかげで、すっかりWinRTも浸透してきたようです。
(写真に登場するLumia 1520とプロ生ちゃんはフィクションであり、この記事とは無関係ですw)
以前に作ったスクレイピングのライブラリは、
「SgmlReader」と呼ばれるものをPortable Class Library化したものです。このライブラリは、「SGML」をパースしてXmlReader化するものですが、HTMLのDTDが添付されており、要するに「普通のHTMLパーサー」として使えるところがミソです。
移植した際に、Profile1ベース(つまり、最初期のPCLで、環境的に何でもOK)という縛りをつけたので、ストアアプリ前までの環境では、あらゆる環境で使用可能です。
この柔軟性はかなり美味しくて、他に依存しているパッケージも存在しないため、どのようなプロジェクト(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の流儀から外れているのか、ゴミファイルが散乱したりして、落ち着いて保存できないのもモヤモヤしていました。
本番
そして、今回のバージョンアップです。まず、ストアアプリ向けのPCLプロジェクトを追加します。前のバージョン(Profile1)と併存させるため、わざとVisual Studio 2012で作ります。
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プロジェクトのダイアログですが、「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>
そして、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を指定するようにしました。
実際に使ってみる
これで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(); } } }
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さん、お願いします!