[OutSystems]HTMLから特定の要素の情報を抜き出してみる

特定のWebページ（OutSystemsのRelease Notes) を監視して、更新があったらその情報を抜き出したい。
ちょうどいい機能が見つからなかったので、NuGetからHTMLをパースできるライブラリを持ってきてExtensionにすることで実現してみようと思って調べていた。

が、Release Notesのページは、JavaScriptで後からコンテンツをロードする形式らしく、パースする元となるHTMLを取得するにはブラウザを経由するなりしないとだめそう。というわけで、もともとやりたかったことは別途方法を考えるとして、OutSystemsからActionを経由してHTMLをパースし、特定情報を抜き出す方法としてメモを残しておく。

Extensionには2種類あって、
1. 外部のRDBと接続してそのテーブルをEntityとして取り込む
1. C#のコードをActionとして取り込む

だが、ここではExtensionは2の方を指す。

# 環境
Personal Environment(Version 11.17.0 (Build 36291))
Service Studio(Version 11.53.21)
Integration Studio(Version 11.11.5)
Visual Studio 2019
AngleSharp(Version 0.17.1)

# サンプルモジュール
Forgeコンポーネント: [HousesoftSampleExtension](https://www.outsystems.com/forge/component-overview/13723/housesoftsampleextension)のV1.0.4。

Demoモジュール > MainFlow > ParseHtmlTest Screen
サンプルAction (Extension): GetLastUpdatedFromHtml

Release NotesのHTML構造が当初想定と違っていたので、テストするにはこの記事の動作確認のセクションの手順を参照。

# 参考資料
公式コースの[Using C# Code - Using a NuGet Package](https://www.outsystems.com/training/lesson/1840/using-a-nuget-package)が、ちょうどNuGetからライブラリを探してきてExtensionにラッピングする手順を解説している。
残念ながら、現時点では日本語訳がなさそうだが、幸い、動画ではなくPDF資料なのでまだ見やすい。

# 仕様検討
## サンプル問題
OutSystemsの製品更新情報を伝えるRelease Notesには残念ながらRSSがない。
それなりの頻度で更新されているが、こちらから見に行かないと更新があったことに気づけない。
そこで、Timerを使って定期的にページをチェックし、更新があったときには通知を受けられるようにしたい。

その一環として、[Release Notes](https://success.outsystems.com/Support/Release_Notes)のHTMLから、「Last updated: 」の後の日付をテキストで取得したい。

## I/F
機能はOutSystemsのモジュール内で利用できるActionとして実装する。

- Action名：GetLastUpdatedFromHtml
- 機能：OutSystemsのRelease NotesページのHTMLを受け取り、「Last updated: 」の後の部分をテキストで返す
- Input Parameter: (Text型）Release NotesのページにHTTP GETでリクエストを行って得たHTML
- Output Parameter: (Date型）ページ上部にある「Last updated: 」以降のテキストを日付にキャストしたもの

## HTMLからの情報取得方法
Release Notesのページをブラウザで開き、開発者ツールで該当要素をチェックしてみる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/64e4ed93-9a45-e615-bf9c-79158d7dd2ef.png)

上の画像内にあるセレクタでもいいが、Chromeの開発者ツール（F12キーで開く）でElementsタブを開き、該当要素を右クリック > Copy > Copy selectorと選択すると、該当要素を指定するCSS Selectorが取得できる。
この方法で該当要素のタグを取得し、その内部テキストを取得すればよいだろう。
 (自動生成されていそうな要素のidを使っているので、ページ構造が変わると壊れそうだが、この記事の本題は、「OutSystemsのフローから利用できるHTMLパーサーを作ってみる」なので置いておく）

``` css
#b1-b4-ContentsToC > div:nth-child(1) > div > span
```

## 利用する.NETライブラリ
ExtensionではC#のコードをラップすることができるが、当然ながら複雑な機能を自分で全部作り上げるのは効率が悪い。
.NETでよく使うOSSのレポジトリとしてNuGetというものがある。そこで、HTMLをCSS Selectorで操作できるものを探すと、[AngleSharp](https://github.com/AngleSharp/AngleSharp)というものが見つかったのでこれを使うことにする。

Visual StudioのNuGetパッケージマネージャーでhtml parserというキーワードで検索をかけたところ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/7ba4591a-12f8-aa24-b1c6-33c26ed67820.png)

# Extensionを作成する
Extensionを作成するには、Service Studioで格納先のアプリケーションを開き、モジュール作成時にmodule typeとしてExtensionを選ぶだけでよい。
できたExtensionを開くと、Integration Studioという別のIDEが立ち上がる。

## Actionを作成
Integration Studioで操作する。
Actionsフォルダに以下のようにActionを定義した。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/c7ae2987-3930-d988-9837-7ed68f336526.png)

Integration Studioはこの定義に従って、C#のコードテンプレートを作ってくれる。
ツールバーの一番右のアイコン（C#と書いてあるもの）をクリックすると、Integration Studioで行った定義に対応するC#のプロジェクトがVisual Studioで開く。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/dabb9ba4-978d-c7a9-f44e-d70b9eb8a5f6.png)

## NuGetライブラリを取り込む
ここからは、Visual Studioで操作する。
### プロジェクトにAngleSharpをインストール
開いたプロジェクト名を右クリック > NuGet パッケージの管理(N)を選択。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/f542b77f-7474-3a21-ec54-17aac3b93842.png)

AngleSharpを選択し、右に出てくるライブラリの詳細から「インストール」ボタンをクリック。確認ダイアログで「OK」ボタンをクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/76eba8cd-78de-fc10-b4df-aa2b7b7d1be5.png)

### AngleSharpが出力ディレクトリにコピーされるようにする
参考資料の公式コースのPDFに以下の通り書いてある。Extensionで参照する外部ライブラリは、Extension内の適切なフォルダに配置される必要があるということ。

> NOTE: Due to the way Integration Studio packs extensions, any external
assemblies or resources that are required for the extension to function will need
to reside inside the extension folder, on the appropriate subdirectory. The Copy
Local property ensures this (i.e. that CommonMark.dll is inside the bin/ folder).

これを実現するには、プロジェクトの下にある「参照」を展開し、AngleSharpを選択。「ローカルにコピー」プロパティをTrueにする（試しに操作してみたら最初からTrueだったが）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/c97af987-0d21-5b9e-2f39-8a02bbe154ac.png)

## 実装
### AngleSharpのドキュメント
https://github.com/AngleSharp/AngleSharp/blob/devel/docs/README.md

### C#コード
Integration Studioで定義したActionと同名のメソッドが、\<Extension名\>.csファイルにあるので、その実装を以下のようにする。
内容は、

- 受け取ったHTMLから指定したCSSセレクターに該当する要素を取得
- 要素のタグ内のテキストを取得
- テキストに含まれる日付を文字列から日付に変換して返す

``` cs
/// <summary>
/// OutSystemsのRelease NotesページのHTMLを受け取り、「Last updated: 」の後の部分をテキストで返す。
/// </summary>
/// <param name="ssHTML">Release NotesのページにHTTP GETでリクエストを行って得たHTML</param>
/// <param name="ssLastUpdated">ページ上部にある「Last updated: 」以降のテキスト</param>
public void MssGetLastUpdatedFromHtml(string ssHTML, out DateTime ssLastUpdated) {
	var cssSelector = "#b1-b4-ContentsToC > div:nth-child(1) > div > span";	// Chromeで該当ページを見つけて取得したCSSセレクター
	try
	{
		// 1 HTMLデータをパースするためのクラス
		var parser = new HtmlParser();
		// 2 パースしてDOM構造を取得
		var document = parser.ParseDocument(ssHTML);
		// 3 ページ内で、指定のCSSセレクターに合致するDOM要素を一つ取得する(複数ある場合は最初の１つ)
		// 想定される対象DOM要素→<span data-expression="" class="font-size-xs text-neutral-3 text-neutral-4">Last updated: Oct 19, 2022</span>
		var targetElement = document.QuerySelector(cssSelector);
		// 4 対象DOM要素のタグ内テキストを取得
		// 想定されるテキスト→Last updated: Oct 19, 2022
		var targetElementText = targetElement.TextContent;
		// 5 日付部分を切り出して、DateTimeにキャストする
		var textAfterPrefix = targetElementText.Substring("Last updated: ".Length);
		ssLastUpdated = DateTime.Parse(textAfterPrefix);
	}
	catch
	{
		// 問題があれば、とりあえずデフォルト値を返し、安全に終了する。
		// 実際に使うときは、要件に応じてログ出力したり、別の出力パラメータにエラー情報をセットしたりすることを検討する
		ssLastUpdated = new DateTime();
	}
} // MssGetLastUpdatedFromHtml
```

## Publish
まず、念のためVisual Studioでビルドして成功することを確認する（Visual Studioでビルドできないようであれば、Publishも成功しないので）。
次にVisual Studioを閉じて、Integration Studioに戻る。

Publishする前に、NuGetから参照しているライブラリの情報を管理するpackages.configをExtensionに含める。
Integration StudioでResourcesタブを開き、packages.config (文字が灰色になっているはず。灰色の文字はExtensionには含まれないことを示す）を右クリックして、Include in Extensionを選択する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/5c6262d1-6e31-fbaf-c9a4-104c6389cffa.png)

これについては、参考資料の公式コースのPDFに以下の通り書いてある。Visual Studioが対象のライブラリのバージョンを追跡するのに必要とのこと。
> In this case we decided to override the default (of ignore) for
packages.config. We do this so Microsoft Visual Studio can keep track of the
CommonMark version we are currently using in the project.

ここまで終わったら、最後にPublish。
サイズこそ小さいが、Service Studioにおける1-Click Publishと同じ形のアイコンをクリックする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/f88401de-375d-296a-afa9-0edd00ec48eb.png)

### トラブルシュート：（エラー）app.configが見つからない
Publishのステップに、.NET Compilationというのがあるがそれに失敗する。
プロジェクトに自動で含められたapp.configという構成ファイルがない場合に発生。私の知る限りExtensionにapp.configは必要ないはずなので、visual Studioでプロジェクトを開き直し、プロジェクトからapp.configを削除することで対応。

```
app.config : error MSB3249: アプリケーション構成ファイル "app.config" は無効です。ファイル 'C:\...\Source\NET\app.config' が見つかりませんでした。 
```

### トラブルシュート：（警告）\<Extension名\>dll.configが見つからない
Publishの途中で、Missing Fileという警告が出ることもあった。これは、dllのconfigファイルが自動でExtensionに含まれていたが、ファイルシステムにはないというもの。Integration StudioのResourcesタブを開くと、たしかに何故かIncludeされている。不要のファイルのはずなので、Resourcesタブで該当ファイルを右クリック→Exclude from Extensionする。

```
The file 'C:\...\Source\NET\Bin\OutSystems.NssHousesoftSampleExtension.dll.config' was not found on the file system.
```

## 動作確認
当初の想定と違って、Release NotesページのちゃんとしたHTMLを得るのに面倒な手順がいる。
JavaScriptでコンテンツのロードを待たないといけないので、以下の手順（この手順はChromeの場合）

1. ブラウザで[Release Notes](https://success.outsystems.com/Support/Release_Notes)にアクセスする
1. 開発者ツールを起動（F12とかで）
1. Elementsタブ内でhtmlタグを選択→右クリックしてEdit as HTMLを選択→HTMLのテキストを編集する画面になるので全テキストをコピー
1. コピーしたHTMLをActionに渡してやる

結果は以下の通り
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/2b74aa80-0f42-9152-0c90-2e82af079d01.png)

まあ、普通のWebページにここまでの手順と同じことをやる場合は、ページのソースを表示すれば、必要なHTMLは取得できるはず。
上記の手順は、あくまでJavaScriptであとからコンテンツをロードするページだからこんなに面倒になっている。