Xamarin.FormsとHtmlAgilityPackでスクレイピング!

[初心者さん・学生さん大歓迎!] Xamarin その2 Advent Calendar 2016 - Qiitaの5日目です。

今回はXamarinを使ってスクレイピングに挑戦してみます。
C#ということで、Reference Sourceの検索部分を取得し、解析します。

プロジェクト作成

まず、Xamarin.Forms 入門ガイド - Xamarin : XLsoft エクセルソフトを参考にプロジェクトを作成します。若干分類が変わっていたりしますが、Blank App (Xamarin.Forms Portable)のプロジェクトを作ればOK。 今回は「RSTest」という名前にしたので適宜読み替えてください。
しばらく待つとWindowsのターゲット確認が出ますが、これもそのままでOKボタン。(ターゲット バージョンは最新に、最小バージョンは一番古いのを選択しておけば2016年12月現在、大丈夫です。)

このあと、HtmlAgilityPackを導入するのですが、念のために一度ビルド。それなりに時間がかかりますが、正常なことを確認。まぁ、Xamarinなので。
UWPなら[ビルド]→[構成マネージャ]のUWPの欄の「ビルド」と「配置」にチェックを付ける。
AndroidならAndroidSDKからAPI24以降を消し去るなど、謎の動作が必要ですが、まぁ、頑張ってググってください。

HTMLAgilityPackのインストール

さて、いよいよHtmlAgilityPackを導入します。ソリューションエクスプローラの「参照」を右クリックし、[NuGet パッケージの管理]を選択。
NuGet パッケージの管理

検索欄に「HTML」とでも入力すると一番上にHtmlAgilityPackが出てくるので、インストールします。
NuGet パッケージの管理

次に「ソリューションエクスプローラ」の「プロジェクト名(移植可能)」となっている部分を右クリックし、[追加]→[新しい項目]ダイアログの[Cross-Platform]カテゴリ、[Forms Content Page]を選択します。
名前は適当で。今回は「Page1.xaml」のままにしちゃいました。 続いて、同様の手順で今度は「クラス」を作成します。名前は「Page1ViewModel.cs」です。

プログラム

本当はGithubにアップしようとか考えてたのですが、ハッカソン中にWindowsリカバリとかをしてたらめげました。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="RSTest.Page1">
  <StackLayout>
    <StackLayout Orientation="Horizontal">
      <Entry Text="{Binding Name}" WidthRequest="100"/>
      <Button Command="{Binding Search}" Text="Search" WidthRequest="100"></Button>
    </StackLayout>
    <ListView ItemsSource="{Binding SearchResults}" x:Name="listView">
      <ListView.ItemTemplate>
        <DataTemplate>
          <ViewCell>
          <StackLayout Orientation="Horizontal">
            <Image Source="{Binding Image}" HeightRequest="32" WidthRequest="32" VerticalOptions="Start" />
            <Label Text="{Binding Kind}" TextColor="Blue" />
            <Label Text="{Binding Name}" />
            <Label Text="{Binding FullName}" />
          </StackLayout>
          </ViewCell>
        </DataTemplate>
      </ListView.ItemTemplate>
    </ListView>
  </StackLayout>
</ContentPage>

page1.xaml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using Xamarin.Forms;

namespace RSTest
{
    public partial class Page1 : ContentPage
    {
        public Page1()
        {
            InitializeComponent();
            this.BindingContext = new Page1ViewModel();
            listView.ItemSelected += (_,e) => Device.OpenUri((e.SelectedItem as RSType).Uri);
        }
    }
}

Page1ViewMode.cs

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;
using Xamarin.Forms;
using HtmlAgilityPack;

namespace RSTest
{
    class Page1ViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged =delegate { };

        private string name;
        public string Name
        {
            get { return name; }
            set { this.name = value; OnPropertyChanged(nameof(Name)); }
        }
        private IEnumerable<RSType> searchResults;
        public IEnumerable<RSType> SearchResults
        {
            get { return searchResults; }
            set { this.searchResults = value; OnPropertyChanged(nameof(SearchResults)); }
        }

        protected virtual void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this,new PropertyChangedEventArgs(propertyName));
        }

        public ICommand Search { get; set; }
        public ICommand ItemTapped { get; set; }
        public Page1ViewModel( )
        {
            Search = new Command(SearchName);
        }

        private async void SearchName()
        {
            using (var client = new HttpClient())
            using( var stream = await client.GetStreamAsync("https://referencesource.microsoft.com/api/symbols/?symbol=" + Name))
            {
                var htmlDoc = new HtmlAgilityPack.HtmlDocument();
                htmlDoc.Load(stream);
                var items = htmlDoc.DocumentNode.Descendants("div").Where(e => e.GetAttributeValue("class", "") == "resultItem");

                SearchResults = items.Select(r => {
                    string kind = r.Descendants("div").Where(e => e.GetAttributeValue("class", "") == "resultKind").FirstOrDefault()?.InnerText;
                    string name = r.Descendants("div").Where(e => e.GetAttributeValue("class", "") == "resultName").FirstOrDefault()?.InnerText;
                    return new RSType()
                    {

                        Uri = new Uri("https://referencesource.microsoft.com" + r.ParentNode.GetAttributeValue("href", "")),
                        Image = new Uri("https://referencesource.microsoft.com" + r.Descendants("img").FirstOrDefault()?.GetAttributeValue("src", "")),
                        Kind = kind,
                        Name = name,
                        FullName = r.Descendants("div").Last().InnerText
                    };
                });
                
            
            }
        }
    }
    public class RSType
    {
        public Uri Uri { get; set; }
        public Uri Image { get; set; }
        public string Kind { get; set; }
        public string Name { get; set; }
        public string FullName { get; set; }
    }
}

ViewModelとかは適当ですが、そこら辺はご勘弁を。実行結果はこうなります。
Androidのスクリーンショット

以下、参考にしたURLです。


解説など

やっていることは至ってシンプル、検索結果を解析し、リストに表示します。あんまりしっかり整形してないのは勘弁してください。
クリックするとブラウザを開き該当箇所にジャンプします。

さて、上記の参考URLのときからHtmlAgilityPackにアップデートがあったようで、NuGetから本体をそのまま落とせば良くなっています。
一方で、致命的な問題、CSSSelectorやXPathが使えないは結局解決していないようです。
これはHTML Agility Packが依存しているSystem.Xml.XPath名前空間以下がPCL(全プラットフォームで共通して使うソース)向けに用意されていないためです。 結局、このプログラムではLINQでねじ伏せるという方針になりました。
Descendeantsを使って子孫をすべて取得し、Whereでclassをチェックして存在すれば追加する、と言った面倒なコードになってしまっています。(null結合演算子?.があるのでそれでもマシにかけてはいます。)

やっぱり不便なので、これがどうにか解決できないか、NuGetで色々探し回ったり、corefxからSystem.Xml.XPathを取得してきてコンパイルしようとしたり色々苦しんだのですが、結局うまく行きませんでした・・・

Xamarin.AndroidでHtmlAgilityPackを使う - Qiita

を見ると少なくともXamarin.Android単品でなら行けるはずです。

もしどなたか、Xamarin.Formsですんなりとスクレイピングをする方法があるようでしたら教えてください!
RubyのMechanizeみたいなライブラリが誕生してほしいですね・・・