kintone

puppeteerを利用したキントーンプラグイン設定の自動取得と反映

はじめに

開発環境から本番環境へのリリース管理をしているのですが、プラグインを扱う場合は
少し工夫が必要です。

弊社はキントーンプラグインとしてkrewSheetを利用しています。
※krewSheetについてはこちら

krewSheetなどを含め、プラグイン固有の設定情報を別の環境に反映したいとき、エクスポート/インポートボタンを利用します。

インポート/エクスポートボタン


別環境に反映する作業ミスを防ぐため、puppeteerを利用して、上述のボタン操作含めブラウザ操作を自動化しています。

puppeteerについて

puppeteerとはプログラムからchromeのブラウザを操作することができるNode.jsライブラリです。
詳細についてはこちらをご覧ください。

いわゆるヘッドレスブラウザの一つで、他にも「selenium」などもあるのですが、弊社として以下理由からpuppeteerを採用しています。

①キントーンが配布しているツール(customize-uploader)が Node.jsを基本としているため、環境を合わせたい

②npmのコマンドでインストールできるため比較的導入が容易
 「selenium」はポート設定などがあり導入を見送りました。

puppeteerのインストール

前提として、事前にNode.js およびnpmをインストールしてください。
弊社環境では以下となります。

Node.jsv11.15.0
npm6.1.0

インストールはnpmコマンドを実行するだけで終了です。

//puppeteerをインストール
npm install puppeteer

プラグイン設定の自動取得

例としてkrewSheetのプラグイン設定の取得方法を説明します。
最終的には上述したインポート/エクスポートボタンを押すことができればOKなのですが、そのボタンに行きつくまでの画面遷移もpuppeteerにて実装する必要性があります。

ブラウザ操作方法

ブラウザ操作としては以下の流れとなります。この流れをpuppeteerにて実装します。
①:キントーンログイン画面に移動。ログインIDとパスワードを入力して、ログインボタンをクリック

②:プラグインの設定画面に移動します。
実際のブラウザ操作としては、トップページ→アプリ選択→設定…等、色々な操作が必要ですが、URLが自明なため直接 プラグインの設定画面 に移動します。
https://{kintoneドメイン}.cybozu.com/k/admin/app/{アプリID}/plugin/config?pluginId={プラグインID}

移動後、エクスポート/インポートボタンのある、「設定ファイル」タブをクリックします。

③-1:エクスポートの場合は、「エクスポート」のリンクから「エクスポート設定ファイル」ボタンをクリックして、ファイルをローカルにダウンロードします。

③-2:インポートの場合は、「 インポート」のリンクから 「インポート設定ファイル」ボタンをクリックした後、ファイル選択ダイアログが出るため、対象のファイルを選択します。

その後、画面が切り替わった後、「プラグイン設定を保存」ボタンを押して、終了です。

エクスポートボタン(実装例)

以下のようなコードで、上述のkrewSheetのエクスポート機能を実施します。
コード記載の「dest_dir」にエクスポートファイルをダウンロードします。

//---------------------------
//必要なライブラリ
//---------------------------
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require("path");
const makeDir = require("make-dir");
require('date-utils');

//------------------------------------------------
//各種環境設定
//お使いの環境に合わせて変更してください。
//------------------------------------------------
// キントーンのドメイン
const domainName = 'XXXXXXXXXXXXXXXXXXXX.cybozu.com'; 
// キントーンのログインID
const login_id   = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXX';    
 // キントーンのパスワード
const password   = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXX';   
// エクスポートしたいプラグインのID
const plugin_id  = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 
 // エクスポート対象の アプリID
const app_id     = 'XXXXXX';
// ゲストスペース利用時はスペースID
const space_id   = null;     
 // エクスポートファイルのファイル保存先
const dest_dir   = 'C:\\XXXX\\XXXXXXXXX';

// ゲストスペース用にURLを加工
var url_space = space_id  ? '' : `guest/${space_id}/`

/*
 * puppeteerを使ってkrewSheetの定義をエクスポート
 */
async function getKrewSetting(app_id , dest_dir , domainName , plugin_id , space_id) {
    // -------------------------
    // puppeteer初期設定
    // -------------------------
    // ブラウザ起動
    const browser = await puppeteer.launch({headless: false});
    // 空ページをいったん立ち上げる
    const page = await browser.newPage()

    //------------------------------------------------------------------------
    //①:キントーンログイン画面に移動。
    //ログインIDとパスワードを入力して、ログインボタンをクリック
    //------------------------------------------------------------------------
    //ログイン画面に移動
    await page.goto(`https://${domainName}/login`, {});
    //1秒待機
    await page.waitForTimeout(1000);

    // ログインIDとパスワード入力
    await page.type('.form-text[name="username"]', login_id);
    await page.type('.form-text[name="password"]', password);

    // ログインボタンクリック
    const buttonElement = await page.$('.login-button');
    await buttonElement.click();
    //1秒待機
    await page.waitForTimeout(1000);

    //------------------------------------------------------------------------
    // ②:プラグインの設定画面に移動します。
    // URL:https://{kintoneドメイン}.cybozu.com/k/admin/app/{アプリID}/plugin/config?pluginId={プラグインID}
    //------------------------------------------------------------------------
    await page.goto(`https://${domainName}/k/${url_space}admin/app/${app_id}/plugin/config?pluginId=${plugin_id}`, {});
    //5秒待機
    await page.waitForTimeout(5000);

    //------------------------------------------------------------------------
    //移動後、エクスポート/インポートボタンのある
    //「設定ファイル」タブをクリックします。
    //------------------------------------------------------------------------
    await page.click('#file-menu-tab');
    //2秒待機
    await page.waitForTimeout(2000);

    //------------------------------------------------------------------------
    //③-1:エクスポートの場合は、「エクスポート」のリンクから
    //「エクスポート設定ファイル」ボタンをクリックして、
    //ファイルをローカルにダウンロードします。
    //------------------------------------------------------------------------
    // 「エクスポート」のリンクをクリック
    await page.click('.filemenu .menu-panel a[rv-html="res.ribbon_file_menu_export"]');
    //2秒待機
    await page.waitForTimeout(2000);

    // ---------------------------------------------------------------
    // ファイルダウンロード設定
    // ---------------------------------------------------------------
    // ダウンロード先の一時フォルダ
    var dt = new Date();
    var formatted = dt.toFormat("YYYYMMDDHH24MISS");
    var downloadPath = require("path").join(__dirname, formatted);

    // ダウンロード設定
    await page._client.send('Page.setDownloadBehavior', {
      behavior: 'allow', // ダウンロード許可
      downloadPath: downloadPath,
    });

    //「エクスポート設定ファイル」ボタンをクリック
    await page.click('#export-json');
    await page.waitForTimeout(2000);
    
    // ダウンロード時のsleep設定
    function sleep(milliSeconds) {
      return new Promise((resolve, reject) => {
        setTimeout(resolve, milliSeconds);
      });
    }

    //ダウンロードが完了するまでウェイト
    let filename = await ((async function(){
      let filename;
      while ( ! filename || filename.endsWith('.crdownload')) {
          filename = fs.readdirSync(downloadPath)[0];
          if(filename == undefined){
            //何もしない
            await sleep(5000);
          }else{
            //ファイルの拡張子がcrdownloadの場合スルーする
            console.log(filename)
            var ext = filename.slice( -10 );

            if(ext == "crdownload"){
                //何もしない
                await sleep(2000);
            }else{
                //ファイルのフルパス
                let fullname = downloadPath + "\\" + filename;
                var dest_filename = 'gcks.json'
                //フォルダ移動
                fs.copyFile(fullname, dest_dir + "\\" + dest_filename, (err) => {
                    if (err) throw err;

                    //一時フォルダ内のファイルを削除
                    fs.unlinkSync(fullname);

                    //一時フォルダを削除
                    fs.rmdirSync(downloadPath);

                    console.log(`ファイル移動に成功しました 【${dest_dir + "\\" + dest_filename}】`);
                });
                await sleep(2000);
            }
          }
      }
      return filename
    })());

    // ブラウザを終了
    await browser.close();
    return true;
}
// エクスポートを実施
getKrewSetting(app_id , dest_dir , domainName , plugin_id , space_id);

コード説明

実装例が長い為、いくつか説明をしてきます。

    // -------------------------
    // puppeteer初期設定
    // -------------------------
    // ブラウザ起動
    const browser = await puppeteer.launch({headless: false});
    // 空ページをいったん立ち上げる
    const page = await browser.newPage()

puppeteer起動時のオプションとして「headless」をfalseにすると、実際にブラウザが立ち上がって操作を行います。バックエンドで利用したい場合はtrueを指定してください。

    //ログイン画面に移動
    await page.goto(`https://${domainName}/login`, {});
    //1秒待機
    await page.waitForTimeout(1000);

    // ログインボタンクリック
    const buttonElement = await page.$('.login-button');
    await buttonElement.click();
    //1秒待機
    await page.waitForTimeout(1000);

画面遷移時やボタンクリック後、数秒待機しています。
puppeteerは特定の要素が出るまで待つことなどもできるのですが、javascript側の終了を待つのが難しく、最終的に秒指定で待つようにしています。
▽参考サイト
Puppeteerで次ページへの遷移を待つ

    // ダウンロード先の一時フォルダ
    var dt = new Date();
    var formatted = dt.toFormat("YYYYMMDDHH24MISS");
    var downloadPath = require("path").join(__dirname, formatted);

    // ダウンロード設定
    await page._client.send('Page.setDownloadBehavior', {
      behavior: 'allow', // ダウンロード許可
      downloadPath: downloadPath,
    });

「setDownloadBehavior」でダウンロード時の一時フォルダを設定しています。
最終的には「dest_dir」フォルダにファイルを移動しております。

 // エクスポートファイルのファイル保存先
const dest_dir   = 'C:\\XXXX\\XXXXXXXXX';


ダウンロード時とフォルダを分けていますが、まとめてしまっても問題ありません。
ファイル名は固定で「gcks.json」としています。
※ダウンロード時のソースは複雑に書いてしまいました。もう少しシンプルに書けるかと思います。

インポート(実装例)

エクスポートと内容はほとんど同じです。
違いとして78行目の「③-2」以降がインポート処理となります。

//---------------------------
//必要なライブラリ
//---------------------------
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require("path");
const makeDir = require("make-dir");
require('date-utils');

//------------------------------------------------
//各種環境設定
//お使いの環境に合わせて変更してください。
//------------------------------------------------
// キントーンのドメイン
const domainName = 'XXXXXXXXXXXXXXXXXXXX.cybozu.com'; 
// キントーンのログインID
const login_id   = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXX';    
 // キントーンのパスワード
const password   = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXX';   
// エクスポートしたいプラグインのID
const plugin_id  = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 
 // エクスポート対象の アプリID
const app_id     = 'XXXXXX';
// ゲストスペース利用時はスペースID
const space_id   = null;     
 // エクスポートファイルのファイル保存先
const dest_dir   = 'C:\\XXXX\\XXXXXXXXX';
// ゲストスペース用にURLを加工
var url_space = space_id  ? '' : `guest/${space_id}/`

/*
 * puppeteerを使ってkrewSheetの定義をエクスポート
 */
async function setKrewSetting(app_id , dest_dir , domainName , plugin_id , space_id) {
    // -------------------------
    // puppeteer初期設定
    // -------------------------
    // ブラウザ起動
    const browser = await puppeteer.launch({headless: false});
    // 空ページをいったん立ち上げる
    const page = await browser.newPage()

    //------------------------------------------------------------------------
    //①:キントーンログイン画面に移動。
    //ログインIDとパスワードを入力して、ログインボタンをクリック
    //------------------------------------------------------------------------
    //ログイン画面に移動
    await page.goto(`https://${domainName}/login`, {});
    //1秒待機
    await page.waitForTimeout(1000);

    // ログインIDとパスワード入力
    await page.type('.form-text[name="username"]', login_id);
    await page.type('.form-text[name="password"]', password);

    // ログインボタンクリック
    const buttonElement = await page.$('.login-button');
    await buttonElement.click();
    //1秒待機
    await page.waitForTimeout(1000);

    //------------------------------------------------------------------------
    // ②:プラグインの設定画面に移動します。
    // URL:https://{kintoneドメイン}.cybozu.com/k/admin/app/{アプリID}/plugin/config?pluginId={プラグインID}
    //------------------------------------------------------------------------
    await page.goto(`https://${domainName}/k/${url_space}admin/app/${app_id}/plugin/config?pluginId=${plugin_id}`, {});
    //5秒待機
    await page.waitForTimeout(5000);

    //------------------------------------------------------------------------
    //移動後、エクスポート/インポートボタンのある
    //「設定ファイル」タブをクリックします。
    //------------------------------------------------------------------------
    await page.click('#file-menu-tab');
    //2秒待機
    await page.waitForTimeout(2000);

    //------------------------------------------------------------------------
    //③-2:インポートの場合は、「インポート」リンクから
    //「インポート設定ファイル」ボタンをクリックした後、
    //ファイル選択ダイアログが出るため、対象のファイルを選択します。
    //------------------------------------------------------------------------
    //インポートリンクをクリック
    await page.click('.filemenu .menu-panel a[rv-html="res.ribbon_file_menu_import"]');
    await page.waitForTimeout(2000);
    
    // インポート設定ボタンをクリック
    const fileElement = await page.$('#import-file');
    // ファイルダイアログが表示されたらインポート対象のファイルを指定
    await fileElement.uploadFile(dest_dir + "\\gcks.json");
    await page.waitForTimeout(3000);

    // 確認ダイアログが出るので確認
    var ret = await isView('.ui-dialog-buttons .ok_button' , page);
    if(ret != false){
        await page.click('.ui-dialog-buttons .ok_button').then(res => !!res);
        await page.waitForTimeout(3000);
    }
    // さらに確認ダイアログが出る場合があるので再度確認
    var ret = await isView('.ui-dialog-buttons .ok_button' , page);
    if(ret != false){
        await page.click('.ui-dialog-buttons .ok_button').then(res => !!res);
        await page.waitForTimeout(3000);
    }

    // プラグイン設定を保存をクリック
    await page.click('.quick-access-bar .save-block .save');
    await page.waitForTimeout(3000);

    // ブラウザを終了
    await browser.close();
    
    return true;
}

/*
 * 指定要素が表示対象かどうかチェック
 * @param selector
 * @param page
 * @return 表示対象の場合true
 */
async function isView(selector , page) {
    // 確認ダイアログが出るので確認
    var isConfirm = await page.$(selector).then(res => !!res);
    if(isConfirm){
        //画面表示されているかをチェック、非表示の場合はfalseで片脚
        var isNotHidden = await page.$eval(selector, (elem) => {
            console.log(elem);
            return window.getComputedStyle(elem).getPropertyValue('display') !== 'none' && elem.offsetHeight
        });
        return isNotHidden
    }
    return isConfirm;
}
// インポートを実施
setKrewSetting(app_id , dest_dir , domainName , plugin_id , space_id);

コード説明

エクスポート同様、インポートについてもコードの説明を行います。

    // インポート設定ボタンをクリック
    const fileElement = await page.$('#import-file');
    // ファイルダイアログが表示されたらインポート対象のファイルを指定
    await fileElement.uploadFile(dest_dir + "\\gcks.json");
    await page.waitForTimeout(3000);

インポート時はファイルダイアログが表示されるため、対象のファイルを指定しています。

    // 確認ダイアログが出るので確認
    var ret = await isView('.ui-dialog-buttons .ok_button' , page);
    if(ret != false){
        await page.click('.ui-dialog-buttons .ok_button').then(res => !!res);
        await page.waitForTimeout(3000);
    }
    // さらに確認ダイアログが出る場合があるので再度確認
    var ret = await isView('.ui-dialog-buttons .ok_button' , page);
    if(ret != false){
        await page.click('.ui-dialog-buttons .ok_button').then(res => !!res);
        await page.waitForTimeout(3000);
    }

その後、krew仕様で確認ダイアログが最大2回出る場合があるため、OKボタンを選択しています。
isViewの関数は確認ダイアログが画面表示されているかを確認するための関数となります。

実行方法

ソースを適当なファイル名に変更した後、node {ファイル名}で実行が可能です。
chromeブラウザが閉じると完了となります。

ABOUT ME
Fukunaga
Fukunaga
リードエンジニアしてます。フロントからサーバサイドまで、フルスタックで担当。 DBチューニングが得意。最近はキントーン開発、AI/分析作業など従事しています。