[UiPath] ヘルプドキュメントのデッドリンク検証への活用


本記事は [[RPA] UiPath Friends 公式 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/uipathfriends) の18日目の投稿です。

今年の Qiita アドベントカレンダー企画で UiPath Friends コミュニティの存在を知ったばかりの新参者ですが、私の UiPath 活用事例をシェアさせていただきます。

環境は UiPath Studio v2020.10.2 (Community License), Orchestrator なしの Robot 単独実行です。

## ヘルプドキュメントとは

今回とりあげる「ヘルプドキュメント」とは、製品やサービスの取扱説明書のオンライン版に相当するものです。家電製品などの場合は PDF ファイルの形で「冊子」を想定して作られていることが多いですが、ここでは ITサービスのヘルプページの構成として、次のような形を想定しています。

![ヘルプドキュメントの構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147483/b325673a-5e5b-d791-5a56-f5585daf889e.png)

画面上部がヘッダー領域、左側にナビゲーション領域、右側にボディ領域（右端のページ内目次なども含む）、という構成です。

ヘルプドキュメントの例(日本語で提供されているもの)：
- Amazon AWS: https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-welcome.html
- Microsoft Azure: https://docs.microsoft.com/ja-jp/azure/guides/developer/azure-developer-guide
- IBM Cloud: https://cloud.ibm.com/docs/cli?topic=cli-getting-started&locale=ja

本記事は、上の例に示すヘルプドキュメントのトップ（Root）ページから，そのドキュメントのナビゲーション領域にある各ページへのリンクと、ボディ領域に表示された各ページの中に出現する他のページへのリンクを取得し、各リンク先がきちんと存在しているか (いわゆる `404 Not Found` エラーにならないか）を検査するツールを UiPath で構築した例です。

## UiPath ワークフローの構成

ワークフローは3段階(ステージ)に分かれており、トップページからナビゲーション領域になる全リンクの取得、ナビゲーション領域の各リンクに対してのページ内リンクの取得、そして取得したリンクを検査してデッドリンクの検出、となっています。
また、それぞれの実行において、入力と出力はすべてExcelファイルのシートに記録することにしています。


### ステージ1: ナビゲーションリンク取得 `GetNavLinks.xaml`

1. ブラウザを起動
2. **ロケールを加味した URL の確定**
2. トップページの URL にアクセス
3. データテーブル初期化
4. 必要に応じて**子要素を拡張（疑似クリック）**
3. **ナビゲーション領域のリンク検出**
4. データテーブルに保存
4. データテーブルの内容をExcelファイルに記録（シート1）

#### ロケールを加味した URL の確定

ベースのURLとは違うロケール(言語)のURLを指定する場合に使います。
例えば、英語のページのURLが指定されているときに、日本語のページのURLをつくる、などの用途です。

以下では、IBM Cloud Docs でのロケール指定の例です。

ベースの URL が `https://cloud.ibm.com/docs/cli?topic=cli-getting-started` となっているときに、これを日本語のページで開くには、URL のクエリ部分に `locale=ja` を付与します。

このときの引数の構成と、C# の「コードを呼び出し(Invoke Code)」アクティビティの例です。

| 変数 | 方向 | 型 | 値 |
| ---- | --- | ---- | --- |
| url | 入力 | String| ページのURL |
| fragment | 入力 | String| ロケールの指定 (`locale=ja`) など |
| targetURL | 出力 | String| ロケールを含むURL |

```csharp
var ub = new UriBuilder(url);
if (ub.Query != null && ub.Query.Length > 1)
    ub.Query = ub.Query.Substring(1) + "&" + fragment; 
else
    ub.Query = fragment; 
targetURL = ub.ToString();
```

#### 子要素の拡張（疑似クリック）

ナビゲーション領域にあるツリー構造を展開して、すべてのリンクが表示されるようにします。
このため、「JS スクリプトを挿入 (Inject JS Script)」アクティビティを用いて、ブラウザ内で疑似クリックを展開可能な各要素について実行します。

こちらの例は、IBM Cloud Docs での JavaScript の例です。展開可能な要素を CSS セレクタで指定して、`aria-expanded` が `false` のとき、疑似クリックで展開します。ページ全体について1回行うので、引数の `element` `input` は参照していません。

```javascript
function (element, input) {
    const sel = ".bx--side-nav__item";
    document.body.querySelectorAll(sel).forEach((elem) => {
        handleSubTree(elem,sel);
    });
    return "DONE";
}

function handleClick(elem) {
    const sel_ph = "button.bx--side-nav__submenu";
    return new Promise(function (resolve, reject) {
        var placeholder = elem.querySelector(sel_ph);
        if (placeholder) {
            var expanded = placeholder.getAttribute("aria-expanded");
            if ("false" == expanded) {
                placeholder.click();
            }
        }
        resolve(elem);
    });
}

function handleSubTree(elem, sel) {
    handleClick(elem)
    .then((temp) => {
        var children = temp.querySelectorAll(sel);
        if (children) {
            children.forEach((child) => {
                handleSubTree(child, sel);	
            });
        }
    });
}
```

#### ナビゲーション領域のリンク検出

ナビゲーション領域内のリンクをすべて取得します。
このため、「JS スクリプトを挿入 (Inject JS Script)」アクティビティを用いて、ナビゲーションリンクに相当する要素すべてについて、その URL (href) の値と，親要素からの深さ(count,0がトップで1,2,3...と深くなる)を計算します。
それをペア`[count,href]`として結果の配列に追加します。最後に結果の配列に対して`;;;`を区切り文字とするCSVに変換処理をしています。これはExcelに記録するときのデータテーブルにするためです。

```javascript
function (element, input) {
    var result = extractHref(document.body);
    var output = reduceToCSV(result);
    return output;
}

function reduceToCSV(arr) {
    var output = arr.reduce((r, a) => {
    var strrep = a.map(function(item) { return "" + item + "";}).join(";;;")
        return r + strrep + "\r\n";
    }, "");
    return output;
}

function countParents(elem, selector) {
    console.log(elem);
    if(!elem){ return 0; }
    var p = elem.closest(selector);
    if (!p) { return 0; }
    else { return 1+countParents(p.parentElement,selector); }
}

function extractHref(parent) {
    const sel_href = ".bx--side-nav__link";
    const sel_hreftext = ".bx--side-nav__link-text";
    const sel_parent = ".bx--side-nav__item.LeftNav-menu";
    var alinks = parent.querySelectorAll(sel_href);
    var hrefLinks = [];
    if (alinks) {
        alinks.forEach((elem) => {
            var count = countParents(elem,sel_parent,"body")
            var hrefText = elem.querySelector(sel_hreftext).innerText;
            var hrefURL = elem.href;
            hrefLinks.push([count,hrefText,hrefURL]);
	});
    }
    return hrefLinks;
}
```

### ステージ2: ページ内リンク取得 `GetAllAnchorLinks.xaml`

1. ブラウザを起動
2. Excelファイルのシート1を開く
3. シート1の内容を読み込み(Read Range)、データテーブルを構築
3. データテーブルの各行に対して以下を実行：
    3. リンクを開き、**デッドリンク判定**の結果を記録
    4. **ページ内にある画像とリンクを検出**
    5. データテーブルに保存
4. データテーブルの内容をExcelファイルに記録（シート2）

#### デッドリンク判定 `CheckResourceNotFound.xaml`

デッドリンク判定ワークフローは、場合分けによってサブのワークフローを呼び出します。
今回は、対象となるサイトでページが見つからないときに HTML 応答コード 404 を素直に返してくれるかどうかで場合分けをし、さらにサイトごとに固有の「見つかりませんページ」に対応するためにサイトごとにワークフローを分けます。

|  場合分け | 呼び出すワークフロー(例示)  |
| --- | --- |
| 404エラーにならないサイトAの場合 | `A_CheckNotFound.xaml` |
| 404エラーにならないサイトBの場合 | `B_CheckNotFound.xaml` |
| 404エラーが直接返る場合 | `CheckDirect404.xaml` (後述) |

#### 独自の「見つかりませんページ」への対応

IBM Cloud Docs の場合、`https://cloud.ibm.com/docs/notfound?locale=ja` といった形の存在しないサービスのページにアクセスすると「このコンテンツはご利用いただけません。」というページが表示されますが、このときのHTTP 応答コードを見ると `404` ではなく `200` となっています。

```console
% curl -LI "https://cloud.ibm.com/docs/notfound?locale=ja" -o /dev/null -w '%{http_code}\n' -s
200
```
（ちなみに、冒頭の例で示した Amazon AWS で試した `https://docs.aws.amazon.com/ja_jp/notfound` は `404`、Microsoft で試した `https://docs.microsoft.com/ja-jp/notfound` も `404` を返します）

したがって、応答コードでの判定はできないため、ページ内に「このコンテンツはご利用いただけません。」という文字列が特定の位置に出現するかを検査します。

「**テキストの有無を確認 (Text Exists)**」アクティビティを用いて、セレクターで対象となる場所を指定し、出現するテキスト（「このコンテンツはご利用いただけません。」）を指定します。

#### ページ内にある画像とリンクを検出

ページのボディ領域にあるハイパーリンクと画像のソースをすべて取得し、`;` を区切りとするCSV文字列とします。
このため、「JS スクリプトを挿入 (Inject JS Script)」アクティビティを用いて、ページ内のHTMLタグ &lt;a href&gt; と &lt;img src&gt; を取得し、その URL (href,src) の値をCSV文字列にして返します。
`input` には、特定のHTML以下の &lt;a href&gt; と &lt;img src&gt; を検出するように CSS セレクタ を指定します。

```javascript
function (element, input) {
    var result = extractLinks(document.body, input);
    var output = reduceToCSV(result);
    return output;
}

function reduceToCSV(arr) {
    var output = arr.join(";");
    //Excelのセルの文字列上限にあわせてカット
    return output.substring(0,32767);
}

function extractLinks(parent, sel_links) {
    var links = parent.querySelectorAll(sel_links);
    var allLinks = [];
    if (links) {
        links.forEach((elem) => {
            var linkURL = null;
            if (elem.href) {
                linkURL = elem.href;
            } else if (elem.src) {
                linkURL = elem.src;
            }
            if (linkURL) {
                allLinks.push(linkURL);
            }
        });
    }
    return allLinks;
}
```

### ステージ3: デッドリンク検出 `CheckDeadLinks.xaml`

1. ブラウザを起動
2. 出力用データテーブル作成
3. **判定結果のキャッシュ**を初期化
2. Excelファイルのシート2を開く
3. シート2の内容から処理用データテーブル構築
3. 処理用データテーブルの各行に対して以下を実行：
    3. リンク（複数）が記録されているセルの文字列からサブのデータテーブルを構築（1行に1リンク）
    4. その行の検査結果を初期化
    4. サブのデータテーブルの各行に対して以下を実行：
        5. **判定結果のキャッシュ**があるとき: キャッシュを参照
        4. **判定結果のキャッシュ**がないとき: リンクを開き、**デッドリンク判定**の結果を記録
        5. 検査結果に判定結果を追加
    7. 検査結果を処理用データテーブルの内容と併合して出力用データテーブルに「データ行を追加(Add Data Row)」
4. 出力用データテーブルの内容をExcelファイルに記録（シート3）

#### デッドリンク判定（404エラーを直接検出）`CheckDirect404.xaml`

素直に応答コード 404 を返すページについては、アクセスして 404 になるかどうかを検査します。

このワークフローの引数はこのようになっているとします。

| 引数 | 方向 | 型 | 値(の例) |
| --- | --- | --- | ---|
| targetURL | 入力 | String | `https://www.example.com/somelink` |
| pageExists | 出力 | Boolean | False |

フローチャートの内部の変数を次のように定義して、さらに下の表のようにアクティビティにて実際のアクセスをして応答コードを検査します。

| 変数 | 型 |
| --- | --- |
| httpreq | HttpWebRequest  |
| httpresp | HttpWebResponse | 

| アクティビティ | 内容 |
| ---- | --- |
| 代入 (Assign) | `httpreq = CType(WebRequest.Create(targetURL), HttpWebRequest)` |
| 代入 (Assign) | `httpresp = CType(httpreq.GetResponse(), HttpWebResponse)` |
| 条件分岐 (If)  - 条件 (Condition) | `httpresp.StatusCode.value__ = 404` |
| 条件分岐 (If)  - 条件 (Then) | `pageExists = False` |
| 条件分岐 (If)  - 条件 (Else) | `pageExists = True` |


#### 判定結果のキャッシュ

同じURLへのリンクが複数回含まれているときに、何度も同じURLにアクセスして実行速度が低下するのを防止するため、`Dictionary(of String, Boolean)`型のデータを用いて、URL文字列と判定結果 true/false を保持します。URL を検査するときはまずこの Dictionary を参照して、すでに検査済みであればその結果を、未検査であれば実際にアクセスして結果を Dictionary に追加します。

## Robot からの起動

3つのステージの処理をそれぞれ個別のワークフロー XAML とし作成しました。次に、それぞれのワークフローをコマンドラインから Robot で起動するように3つの Windows BAT ファイルを構成しました。
さらに、3つの BAT ファイルを順に実行するためのラッパー(wrapper)となる BAT ファイルもつくりました。

ラッパーとなる BAT と各ステージの BAT ファイルの呼び出し関係は、次のようになります。

```
00run_all.bat -+
               +-> 01run_GetNavLinks.bat
               +-> 02run_GetAnchorLinks.bat
               +-> 03run_CheckDeadLink.bat
```

### BAT ファイルの内容

ラッパーとなる BAT では、トップとなる URL と、ファイル名につける名前、アクセスするロケールの3つを指定します。

```console
> 00run_all.bat "https://cloud.ibm.com/docs/cli?topic=cli-getting-started" "projname" "ja"
```

ラッパーの BAT ファイルからそれぞれの BAT ファイルを順に起動しますが、前後にその時点での日時を出力します。
ふつうに出力すると、最初に起動した瞬間の日時のみの同じ値が複数回出力されるので、`setlocal enabledelayedexpansion` で遅延評価モードにして各変数は `%` ではなく `!` で囲んでいます。

```batchfile:00run_all.bat
@echo off

setlocal enabledelayedexpansion

set "URL=%~1"
set "TAG=%~2"
set "LANG=%~3"
  
echo Testing !URL! for !LANG! to c:\temp\output!TAG!.xlsx
  
echo Stage1 START  !date! !time!
call 01run_GetNavLinks.bat "!URL!" !TAG! "locale=!LANG!"
echo Stage1 DONE !date! !time!
  
echo Stage2 START  !date! !time!
call 02run_GetAllAnchorImageLinks.bat !TAG! "locale=!LANG!"
echo Stage2 DONE !date! !time!
  
echo Stage3 START  !date! !time!
call 03run_CheckDeadLinks.bat !TAG! "locale=!LANG!"
echo Stage3 DONE !date! !time!

endlocal
```

ステージ1用の `GetNavLinks.xaml` の引数は以下のようになっています。


| 引数 | 説明 |
| --- | --- |
| inputURL | トップの URL |
| locale | アクセスするロケール |
| outputExcel | 出力のExcelファイル名 |
| outputExcelSheet | Excelファイル内の出力シート名 |

これを Robot 起動時の引数として指定する、ステージ1用の BAT ファイルは以下のようになります。

```batchfile:01run_GetNavLinks.bat
set "TARGET_URL=%~1"
set OUTPUT_EXCEL='C:/temp/output%2.xlsx'
set LOCALE='%3'
set OUTPUT_EXCEL_SHEET='Sheet1'

set XAML="C:\Users\%USERNAME%\Documents\UiPath\ProjectName\GetNavLinks.xaml"
set ROBOT="C:\Users\%USERNAME%\AppData\Local\UiPath\app-20.10.2\UiRobot.exe"

set INPUT="{'inputURL' : '%TARGET_URL%', 'locale' : %LOCALE%, 'outputExcel' : %OUTPUT_EXCEL%, 'outputExcelSheet' : %OUTPUT_EXCEL_SHEET% }"

%ROBOT% -file %XAML% -input %INPUT%
```

ステージ2用の `GetAllAnchorImageLinks.xaml` の引数は以下のようになっています。

| 引数 | 説明 |
| --- | --- |
| locale | アクセスするロケール |
| inputExcel | 入力のExcelファイル名 |
| inputExcelSheet | Excelファイル内のステージ1のシート名 |

出力シート名は省略していますが、別途引数に追加したり、`ステージ1のシート名` + `ステージ2の接尾辞` のように内部で設定してもよいかと思います。

```batchfile:02run_GetAnchorLinks.bat
set INPUT_EXCEL='C:/temp/output%1.xlsx'
set INPUT_EXCEL_SHEET='Sheet1'
set LOCALE='%2'

set XAML="C:\Users\%USERNAME%\Documents\UiPath\ProjectName\GetAllAnchorImageLinks.xaml"
set ROBOT="C:\Users\%USERNAME%\AppData\Local\UiPath\app-20.10.2\UiRobot.exe"

set INPUT="{'inputExcel' : %INPUT_EXCEL%, 'locale' : %LOCALE%, 'inputExcelSheet' : %INPUT_EXCEL_SHEET% }"

%ROBOT% -file %XAML% -input %INPUT%
```

ステージ3用の `CheckDeadLinks.xaml` の引数は以下のようになっています。

| 引数 | 説明 |
| --- | --- |
| locale | アクセスするロケール |
| inputExcel | 入力のExcelファイル名 |
| inputExcelSheet | Excelファイル内のステージ2のシート名 |

ステージ3の出力シート名についても同様で、ステージ2のシート名から生成するなどします。

```batchfile:03run_CheckDeadLinks.bat
set INPUT_EXCEL='C:/temp/output%1.xlsx'
set INPUT_EXCEL_SHEET='Sheet2'
set LOCALE='%2'

set XAML="C:\Users\%USERNAME%\Documents\UiPath\ProjectName\CheckDeadLinks.xaml"
set ROBOT="C:\Users\%USERNAME%\AppData\Local\UiPath\app-20.10.2\UiRobot.exe"

set INPUT="{'inputExcel' : %INPUT_EXCEL%, 'locale' : %LOCALE%, 'inputExcelSheet' : %INPUT_EXCEL_SHEET% }"

%ROBOT% -file %XAML% -input %INPUT%
```

## さいごに

本記事では、UiPath を用いて Web 上のヘルプドキュメントのデッドリンク検出ツール、いわゆる Web クローラーの機能の一部を実装しました。このような機能は Python などのライブラリを用いてもつくることができますが、ヘルプドキュメントを作成・管理するチームは必ずしもプログラミング経験が豊富な方たちばかりではないということも考えられます。UiPath で作成する経験を共有することで、そういった現場の生産性向上につながるのではないかと思っています。

業務フローを実装するといった、UiPath の一般的な使い方からするとちょっと変化球気味ではありますが、コミュニティのみなさんに少しでもお役に立つことができれば幸いです。