いまだにChromeに対応していないWebサービス、puppeteer-dartとdart2nativeで勝手にCLIを作ってやる!


まえおき

世の中には、この令和の時代になってもなおChromeでは動かないWebサービスがあります。
勤怠システムや資産管理システムなど、業務ユーザしか使わないようなアプリケーションだと多いのではないでしょうか。

いまどき、Chromeでまともに動かないWebサービスを提供するくらいなら、REST APIを提供してほしいものです。でも世の中そんなに甘くはありませんね。

そこで今回は、Chromeで動かなくてイライラするようなWebシステムを、dartを活用して勝手にCLIを作る方法を紹介します。

ベースにする要素技術

dart2native

DartはGoのようにシングルバイナリを作れます。

去年のアドベントカレンダーでサラッと書いてた記事があるので、それを見てください。
dart2nativeで簡易的なコマンドラインツールを作る

puppeteer-dart

これも去年のアドベントカレンダーで、dart2nativeを使ってググレカスするCLIアプリケーションの作り方を紹介していました。

dart2nativeを使ってggrksコマンドを作る

ぐぐれかすをするためには、ブラウザを動かす必要がありますが、その自動操作をするためのライブラリが puppeteer-dart です。(puppeteerをDartに移植された非公式のライブラリです)

文字入力、マウス操作はもちろんのこと、「特定のDOM要素が現れるまで待つ」 「画面遷移するまで待つ」 などができます。

puppeteer-dart のFirefox対応版

ここからが今年の話です。

今年のテーマは Chromeでまともに動かない(けどFirefoxだとかろうじて動く) Webサービスに戦うことでした。
なんか条件が増えてるけど気にしないw

さて、puppeteer-dartは2020/12/05現在、Firefoxの自動操作には対応していません。

・・・じゃあどうする?

Firefox対応させるしかないですね。Firefox対応させました。
https://github.com/xvrh/puppeteer-dart/pull/125

ただ、まだマージされてないんで、pubspec.yamlにはGitのURLを指定する必要があります。

pubspec.yaml
dependencies:
  puppeteer:
    git:
      url: [email protected]:YusukeIwaki/puppeteer-dart
      ref: feature/firefox

Null safety "ではない" Dart SDK

2020年のDartの大きな変化といえば、なんといってもSound null safetyでしょう。
コードを書くときは圧倒的に Null safetyが書きやすいです。

しかし!

dart2nativeはどうもNull safetyじゃない依存ライブラリが1こでもあるとコンパイルが通らないみたいなんです。

 $ dart2native main.dart -o awesome_app
Error: This project cannot run with sound null safety, because one or more project dependencies do not
support null safety:

 - package:cli_dialog
 - package:puppeteer
 - package:meta
 - package:async
 - package:pool
 - package:logging
 - package:archive
 - package:http
 - package:path
 - package:collection
 - package:dart_console
 - package:stack_trace
 - package:petitparser
 - package:crypto
 - package:http_parser
 - package:ffi
 - package:win32
 - package:convert
 - package:string_scanner
 - package:typed_data
 - package:source_span
 - package:charcode
 - package:term_glyph

Run 'pub outdated --mode=null-safety' to determine if versions of your
dependencies supporting null safety are available.


Failed to generate native files:
Generating AOT kernel dill failed!

--no-sound-null-safety オプションも今のところありませんし、いまのところ Dart SDK 2.10 (Null safety非対応)でdart2nativeするしか方法はなさそうです。

(実は方法があるよ!って知ってる人は教えて下さい)

cli_dialog

CLI化する場合には、いまのところ cli_dialogがシンプルかなと思います。

  • テキストで入力をもらう
  • 選択肢から選んでもらう
  • yes/no を回答してもらう

が、簡単にできます。

DartはGoにくらべると、コンソールアプリケーションの便利ライブラリは少なくて、
cli_dialogも、正直かゆくて手が届かないところが多いです。OSSコントリビューションチャンスがいっぱいですね(白目)

組み合わせる

まず自動操作側を作ってしまう

class KusoServiceTop {
  Page page;
  KusoServiceTop(this.page);

  browseToLoginPage() async {
    await page.click("#sub_menu");

    await Future.wait([
      page.waitForNavigation(),
      page.click("li.login_page"),
    ]);
  }
}

class KusoServiceLogin {
  Page page;
  KusoServiceLogin(this.page);

  submitCredential(String username, String password) async {
    await page.waitForSelector("input[name='username']");
    await page.click("input[name='username']");

    await page.keyboard.type(username);
    await page.keyboard.press(Key.tab);
    await page.keyboard.type(password);
    await page.keyboard.press(Key.tab);

    await Future.wait([
      page.waitForNavigation(),
      page.keyboard.press(Key.enter),
    ]);
  }
}

class KusoServiceDataList {
  Page page;
  KusoServiceDataList(this.page);

  list() async {
    final cells = await page.$$(".cell_item");

    // 省略
  }
}

きれいなページオブジェクトになっていなくても、とりあえず画面単位でクラスを分けるとよいです。

メインアプリケーション側のコードは、一旦CLIのプロンプト操作などを一切含まず、上から下に流れるだけのものにします。

main.dart
import 'package:puppeteer/puppeteer.dart';

doWithBrowser(Browser browser) async {
  final page = await browser.newPage();
  await page.setViewport(DeviceViewport());

  await page.goto("http://kusoservice.example.com/");

  final top = KusoServiceTop(page);
  await top.browseToLoginPage()

  final login = KusoServiceTop(page);
  await login.submitCredential("YusukeIwaki", "secretsecret");

  final dataList = KusoServiceDataList(page);
  for(DataItem item in await dataList.list()) {
    print(item);
  }
}

main() async {
  final browser = await puppeteerFirefox.launch();

  try {
    await doWithBrowser(browser);
  } finally {
    await browser.close();
  }
}

CLI部分を作り込む

ID/パスワードを問う

cli_dialogのサンプルほぼそのままですが、以下のように書けば ask() のところで処理がブロッキングされて、ユーザ入力を受け付けます。ユーザ入力が済むと、処理が再開され、ログイン操作が行われます。


  final loginDialog = CLI_Dialog(questions: [
    ["User Name:", "username"],
    ["Password:", "password"],
  ]);
  final loginDialogAnswer = loginDialog.ask();

  await login.submitCredential(
    loginDialogAnswer['username'],
    loginDialogAnswer['password'],
  );

ちなみに、少し話は逸れますが、Goのviperみたいなライブラリは、今のところDartにそんな便利なライブラリはありません。

データを取得してきたものから、1つを選択してもらう

  // itemListが選択肢

  while (true) {
    final selectDataDialog = CLI_Dialog(listQuestions: [
      [
        {
          'question': 'Select data',
          'options':
              ["Exit"] + itemList.map((item) => item.toString()).toList(),
        },
        'data'
      ],
    ]);
    final String selectDataAnswer = selectDateDialog.ask()['data'];
    if (selectDataAnswer == 'Exit') break;

    // selectDataAnswerが選択されたもの
  }

cli_dialog の仕様上どうしても、選択肢は文字列のリストで、選択されたものも選択肢の文字列そのままで返されてしまいます。

たとえば

 12/1: xxxx
 12/2: yyyy
 12/3: zzzz
 12/4: aaaa
 12/7: bbbb

のような選択肢を出すには

["12/1: xxxx", "12/2: yyyy", "12/3: zzzz", ..... ] を渡して、かりに12/7が選ばれると "12/7: bbbb" が文字列として返ってくる、という感じです。

仕様としては非常にシンプルではありますが、「文字列」であることがとてもネックで、
たとえば勤怠データとかだと、「勤怠データから文字列への変換」「文字列から勤怠データへの変換」を用意しないといけません。

このデータのシリアライズ/デシリアライズ処理が増えるのが結構めんどくさいです。
HTMLのselect/optionみたく、表示テキストとvalueが別に指定できればいいんですけどね・・・。

あとは、DartはPythonのようにCtrl+Cをカジュアルに処理しづらいので、選択ダイアログをを抜けるための選択肢(上のサンプルだとExit)を1つ追加しておいたほうが無難です。

完成

(私が作ったのは会社の勤怠システム用のCLIなので、残念ながらスクショ貼れません・・・  そのうち気が向いたらサンプル作って貼ります)

 

今回は cli_dialogを活用したCLIを作る話でしたが、Dart標準のHttpServerを使えばJSON API化することもできるでしょう。

まとめ

令和にもなってChromeに対応していないWebサービスは、おそらく今後もずっとChromeには対応しないでしょう。
そんなWebサービスをわざわざFirefoxに切り替えて使わないといけないストレスから開放されるための、CLIツールの作り方を紹介してみました。(本当の敵はIEじゃないと倒せないかもしれませんがw)

Dartはシングルバイナリにビルドできるし、Goに比べるとゆるふわなコマンドラインアプリケーションを作るのに向いています。

みなさんの周りに、見たくもないWebサービスがもしもあれば、Dartの勉強がてらCLIを作ってみてはいかがでしょうか。