Seleniumを使ってOutDocを自動出力したい

Seleniumを使ったOutDoc（オープンソースのOutSystemsドキュメント出力モジュール）を操作するコードを書いてみました。
ログインから生成されたドキュメントのスクリーンショットを取るところまで。
ただし、長くなったので、1ページずつ取得する画像を1枚にまとめる手順は別途。

# 確認環境
Personal Environment(Version 11.8.0 (Build 12006))
Service Studio(Version 11.7.15)
Visual Studio Code(Version 1.47.2)

あと、NuGetで、以下のSeleniumライブラリを入れています。
Selenium.WebDriver(Version 3.141.0)
Selenium.WebDriver.ChromeDriver(Version 84.0.4147.3001)
Selenium.Support(Version 3.141.0)

ChromeDriverを使う時は、Chromeとバージョンを揃えないと以下のようなエラーが発生しました。

型 'System.InvalidOperationException' のハンドルされていない例外が WebDriver.dll で発生しました: 'session not created: This version of ChromeDriver only supports Chrome version 84 (SessionNotCreated)'

# サンプルコード
https://github.com/jyunji-watanabe/OutSystemsUtilities/tree/master/OutDocExporter

ビルドしたら、.exeと同じ場所にappsettings.jsonをコピーして、PEのURLとログインアカウント、パスワードを設定。

実行は、以下のようにモジュール名をパラメータにする。
`.\OutDocExporter.exe HousesoftSampleReactive`

# OutDocでの画面遷移
ログインからドキュメント出力までは以下のように動きます。
なお、OutDocはTraditional Webという種類のモジュールであるため、各要素のセレクターにidを使うことができません。idが自動生成されるため。

1. ログイン画面：ユーザー名とパスワードを入力してログイン
2. ホーム画面：ログイン後のトップページ。トップメニューからeSpace一覧画面を開く
3. eSpace一覧画面：モジュール名で検索した後、対象モジュールのリンクを開く
4. ドキュメント生成画面：開くとドキュメントの準備が始まる。準備をAjax Refreshで待ち、終わると自動でドキュメントを開くボタンが表示されるのでクリックする
5. ドキュメント画面：開いたら、スクリーンショットをとる

# 実装
## ページオブジェクト
UIテストの文脈で進められているデザインパターン。
各ページ毎に操作用のクラスを分割し、ページ内の操作をクラス内に閉じ込めることで、読みやすく、変更に強くなる。
これから作るのはUIテストではないが、わかりやすさのために踏襲する。

### 1. ログイン画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/f760f71f-314c-0590-cc79-dbc3fd8e12cc.png)


- 実際にはChrome操作用のChromeDriverを使いますが、別のブラウザに変えても使えるようにベースクラスのRemoteWebDriverをコンストラクタのパラメータに使っています
- 各ページオブジェクトクラスのコンストラクタでは、想定通りのページを開けるか確認し、問題があればオリジナルの例外クラスをスロー（ログイン画面ではTitleで）
- SetCredentialメソッドで、パラメータをUsernameとPasswordのテキストボックスに設定している
    - driver.FindElement(By.CssSelector())の呼び出しは、CSSセレクターを使ってHTMLの特定要素を取り出す処理
    - Usernameの入力部品は「table.FindElement(By.CssSelector("tr:nth-of-type(3) input"))」で取得している。これは、tableタグの、3行目にあるinputタグという指定
    - inputタグを取得したら、.Clear()で入力済みの値をきれいにしてから、SendKeysでパラメータの値を設定
- Loginボタンは、ボタンがページ内に一つしかないことから、「input[type=submit]」で指定できる
- 画面遷移するメソッドは、戻り値で遷移先のページオブジェクトクラスを返す


``` cs:LoginPage.cs
using OpenQA.Selenium.Remote;
using OpenQA.Selenium;

public class Login 
{
    private const string PageTitle = "Login";
    private RemoteWebDriver driver;
    public Login(RemoteWebDriver driver)
    {
        this.driver = driver;
        if (this.driver.Title != Login.PageTitle)
        {
            throw new IllegalPageStateException(
                "ページタイトルが想定と異なります（想定：" + Login.PageTitle + "、実際：" + this.driver.Title + "）");
        }
    }

    public void SetCredential(string userName, string password)
    {
        // UserNameとPasswordを入力
        var table = driver.FindElement(By.CssSelector(".MainContent table table"));
        var userInput = table.FindElement(By.CssSelector("tr:nth-of-type(3) input"));
        userInput.Clear();
        userInput.SendKeys(userName);
        var passwordInput = table.FindElement(By.CssSelector("tr:nth-of-type(5) input"));
        passwordInput.Clear();
        passwordInput.SendKeys(password);
    }

    public HomeScreen LoginAndGoToHomeScreen()
    {
        driver.FindElement(By.CssSelector("input[type=submit]")).Click();
        return new HomeScreen(this.driver);
    }
}
```

### 2.ホーム画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/e40525d0-f2bb-6d5c-951e-e69848b73931.png)

このページは中身がないので、赤枠のタブを開くだけの処理。

- 「.Menu_TopMenus」はトップメニューのWeb Block内で、明示的に指定されているクラス名で変更の恐れが無いため使っている
- トップメニュー内の各メニューはContainer Widget(=>divタグ)内にLink Widget(=>aタグ)という構成であることから「.Menu_TopMenus>div>a」でトップメニュー内のメニューリンクを指定できる。nth-of-type(2)は同じ階層にあるdivタグの2番め、すなわち左から2番めのメニューを指している

``` cs:HomeScreen.cs
using OpenQA.Selenium.Remote;
using OpenQA.Selenium;

public class HomeScreen
{
    // (中略)
    public ESpaceList MoveToESpaceList()
    {
        this.driver.FindElementByCssSelector(".Menu_TopMenus>div:nth-of-type(2)>a").Click();
        return new ESpaceList(this.driver);
    }
}
```

### 3.eSpace一覧画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/5e71abb0-d49a-cb8f-3055-3a811ffbd302.png)


トップメニューで、「eSpaces」を選択すると開く画面。

- コンストラクタで、トップメニューの正しいタブが開かれている（Active）になっていることをチェック
- ①モジュール一覧はページングされているため、目標のモジュールが最初に表示されていないことがある　→　検索ボックスにモジュール名を入力してSearchボタンをクリック
- ②検索処理が遅いため、対象モジュールのリンクが表示されるまで待ち処理を入れてある
    - OpenQA.Selenium.Support.UI.WebDriverWaitを使う
    - コンストラクタでは、最長待ち時間に2分を設定しています。環境によって調整してください
    - WebDriverWait.Untilに待ち条件を指定するのですが、色々なサンプルにのっているExpectedConditionsを使った指定はdeprecatedとマークされています。ただ、その代替として挙げられていた匿名関数を使った方法だと待ち判定が発生するたびに例外（条件が満たされていない）が投げられるので、ExpectedConditionsで指定
    - ElementIsVisibleはセレクタで示した要素が表示されるまで待つという条件
- ③モジュール名と一致するリンクは一つしかないはずなので、表示されたらクリック


``` cs:ESpaceList.cs
using OpenQA.Selenium;
using OpenQA.Selenium.Remote;
using OpenQA.Selenium.Support.UI;

public class ESpaceList
{
    private const string TabTitle = "eSpaces";
    private RemoteWebDriver driver;
    public ESpaceList(RemoteWebDriver driver)
    {
        this.driver = driver;
        // トップメニューのアクティブなタブ内テキスト
        var activeTabTextInTheTopMenu = driver.FindElementByCssSelector(".Menu_TopMenuActive>a").Text;
        if (activeTabTextInTheTopMenu != ESpaceList.TabTitle)
        {
            throw new IllegalPageStateException(
                "選択されているタブが想定と異なります（想定：" + ESpaceList.TabTitle + "、実際：" + activeTabTextInTheTopMenu + "）");
        }
    }

    public ESpaceDesignFeedBack OpenESpace(string eSpaceName)
    {
        // 検索キーワードの設定
        var searchInput = this.driver.FindElementByCssSelector(".Filters input[type=text]");
        searchInput.Clear();
        searchInput.SendKeys(eSpaceName);
        // 検索ボタンクリック
        this.driver.FindElementByCssSelector(".Filters input[type=submit]").Click();
        // 検索結果の1ページ目にリンクテキストが、指定eSpace名であるLinkが表示される（＝検索される）のを待ってクリック
        var waitForLinkWitheSpaceName = new WebDriverWait(this.driver, new System.TimeSpan(0, 2, 0));
        // 匿名関数で実装すると、探索のたびに例外を投げるので、deprecatedだが、いったんExpectedConditionsで実装しておく
        waitForLinkWitheSpaceName.Until(ExpectedConditions.ElementIsVisible(By.LinkText(eSpaceName)))
                                 .Click();
        // 対象モジュールのドキュメント生成ページが開く
        return new ESpaceDesignFeedBack(this.driver, eSpaceName);
    }
}
```

### 4.ドキュメント生成画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/332987/f93f4886-7200-3551-2eb7-b1eb34421fc3.png)

画面を開くと、ローディングアイコンが表示されます。しばらくしてドキュメントの準備ができると「Open Documentation」というボタンが表示される。このボタンをクリックすると、目標のドキュメントが開きます。

- ボタンの表示を待つ処理は、基本的に、上でLinkの表示を待ったときと同じ
- セレクタの「.Button[value^=Open]」はButtonクラスがあり、かつvalue属性が「Open」で始まるもの、という指定

``` cs:ESpaceDesignFeedBack.cs
using OpenQA.Selenium;
using OpenQA.Selenium.Remote;
using OpenQA.Selenium.Support.UI;

public class ESpaceDesignFeedBack
{
    // (中略)

    /// <summary>
    /// Open Documentationボタンが表示されるまで待ち、クリックする
    /// </summary>
    public void WaitForButtonAndClick()
    {
        // 最長2分間待つタイマー
        var waitForOpenDocumentButton = new WebDriverWait(this.driver, new System.TimeSpan(0, 2, 0));
        // 「Open Documentation」というvalueを持つボタンが表示されるのを待ち、表示されたらクリック
        waitForOpenDocumentButton.Until(ExpectedConditions.ElementIsVisible(By.CssSelector(".Button[value^=Open]")))
                                 .Click();
    }
}
```

## スクリーンショット取得
以前は、FirefoxDriverを使うと、ページ全体のスクリーンショットを取れたようですが、今は仕様変更でだめでした。
よって、スクリーンショット撮影　→　縦方向にスクロールを繰り返してページ全体のスクリーンショット群を取得する処理にしています。

処理が長くなりすぎたので、画像を1枚にまとめるのは別の機会に。

### 参考記事
[Chromeでフルサイズのスクリーンショットを撮るためのパッチ](https://qiita.com/myhr/items/dff7cee30182ab56f737#%E3%82%B3%E3%83%BC%E3%83%89)
を参考にしました。

OutDocの場合は、横スクロールが必要ないので、縦方向のみにしています。

### コード

- staticメソッドにしてあるので、呼び方は、ScreenShot.TakeWholePageAsScreenShot(driver)
- ちょっとさぼって、出力先は「C:\work\ss」に固定。適宜修正してください
- スクロールの判定には、ページ全体の高さとスクリーンショット1回分の高さをJavaScriptで取得して使っている
    - JavaScriptを実行したい場合は、WebDriverをOpenQA.Selenium.IJavaScriptExecutorインターフェースにキャストして、ExecuteScriptを呼び出す
    - スクロールさせるのもJavaScript
- WebDriverにスクリーンショットを取らせる場合、OpenQA.Selenium.ITakesScreenShotインターフェースにキャストして、GetScreenShotでScreenshotオブジェクトを取得、さらにそのSaveAsFileで指定パスにファイルを保存
- このロジックだと指定パスに日時_00001.png, 日時_00002.png……のようなファイルが出来上がる

``` cs:ScreenShot.cs
using OpenQA.Selenium.Remote;
using OpenQA.Selenium;
public class ScreenShot
{
    public static string Path = "C:\\work\\ss";
    public static void TakeWholePageAsScreenShot(RemoteWebDriver driver)
    {
        // 仕様単純化のため、横スクロールは不要を前提とする
        var jsExecutor = driver as IJavaScriptExecutor;
        // JavaScriptを使って、スクロール回数の計算に使う値を取得
        var totalHeight = (long)jsExecutor.ExecuteScript("return document.body.scrollHeight;");
        var pageHeight = (long)jsExecutor.ExecuteScript("return window.innerHeight;");
        // スクロール制御用
        var scrolledHeight = (long)0;
        var currentImageCount = 1;  // ファイル名末尾に連番をつけるための変数。今何番目の画像かを示す
        var filePathPrefix = Path + "\\" + System.DateTime.Now.ToString("yyyyMMdd_HHmmss_");

        // 1ページ分ずつスクロールしながら、スクリーンショットを撮っていく
        var iTakesScreenshot = (ITakesScreenshot)driver;
        while(scrolledHeight < totalHeight)
        {
            jsExecutor.ExecuteScript($"window.scrollTo(0, {scrolledHeight});");
            iTakesScreenshot.GetScreenshot()
                            .SaveAsFile(filePathPrefix + currentImageCount.ToString().PadLeft(5, '0') + ".png");

            // ループ制御
            currentImageCount++;
            scrolledHeight += pageHeight;
        }
    }
}
```

## メインプログラム

- 設定ファイルから、URL、ユーザー名、パスワード、パラメータからモジュール名を取得
- 処理本体では、順にページオブジェクトを使って画面遷移していき、最後でスクリーンショット

``` cs
using System;
using System.IO;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Interactions;
using Microsoft.Extensions.Configuration;

class Program
{
    static void Main(string[] args)
    {
        if (args.Length == 0)
        {
            System.Console.WriteLine("ドキュメントを出力するモジュール名を指定してください。");
            return;
        }
        var eSpaceName = args[0];
        if (Directory.Exists(ScreenShot.Path) == false)
        {
            System.Console.WriteLine($"出力先フォルダ{ScreenShot.Path}を作成するか、出力先フォルダを変更してください");
            return;
        }
        IConfiguration configuration = new ConfigurationBuilder()
                                            .AddJsonFile("appsettings.json", true, true)
                                            .Build();
        var url = configuration.GetSection("HostAddress").Value + "OutDoc";
        var userName = configuration.GetSection("UserName").Value;
        var password = configuration.GetSection("Password").Value;
        try
        {            
            DownloadDocument(url, userName, password, eSpaceName);
        }
        catch (System.Exception ex)
        {
            System.Console.WriteLine(ex.Message + Environment.NewLine + ex.StackTrace);
        }
    }

    private static void DownloadDocument(string url, string userName, string password, string eSpaceName)
    {
        using (var driver = new ChromeDriver())
        {
            // ログインページヘ遷移し、ログインを実行する
            driver.Navigate().GoToUrl(url);
            var loginPage = new Login(driver);
            loginPage.SetCredential(userName, password);
            var homeScreen = loginPage.LoginAndGoToHomeScreen();
            // eSpacesページへ遷移する
            var eSpacesList = homeScreen.MoveToESpaceList();
            // eSpace名で検索して対象モジュールのみ表示した上で、クリックして開く（ドキュメントページへ）
            var eSpaceDesingFeedBack = eSpacesList.OpenESpace(eSpaceName);
            // 生成されたドキュメントを開く
            eSpaceDesingFeedBack.WaitForButtonAndClick();
            // スクリーンショットを取得
            ScreenShot.TakeWholePageAsScreenShot(driver);

            var debugdummy = "dummy";   // デバッグ用（ブラウザ表示した状態でブレークするため）ダミー行
        }
    }
}

```