20210103のNode.jsに関する記事は12件です。

Raspberry Pi用node.jsでゲームパッド入力をキーボード/マウス入力に変換する

概要

Raspberry Piでゲームパッドの入力をキーボードやマウスの入力に変換するソフトは今の所無いようなので、node.jsでさくっと作ってみた。

(Intel/AMD製CPU用の)Linux・Windows・MacにはGUIで設定できる同様のソフトが存在するので素直にそっちを使ったほうがいいです。

インストール

node.js及び、joystick・robotjsモジュールをインストールする必要があります。
また、robotjsをインストールするためにlibx11-devとlibxtst-devもインストールする必要があるようです(参考)

sudo apt install npm libx11-dev libxtst-dev
npm install joystick robotjs --save-dev

スクリプト

joystickモジュールでゲームパッドの入力を受け取ってその結果を元にrobotjsモジュールを利用してキー・マウス操作する感じになります。

接続されるまでポーリングで待機したり、キー・マウス入力への変換をオブジェクトで定義したりできるようにしてみました。

joykey.js
const fs = require('fs');
const joystick = require('joystick');
const robot = require('robotjs');

var joys = new joystick(jid);

//コンソールにジョイスティックイベントの内容を表示
//var consolelog = true;
var consolelog = false;

//ジョイスティックのID(通常は0)
var jid = 0;

//キーやスティックが押されたときの動作を定義
/*
ジョイスティックのボタン押下(button)
"0", "1", "2"....

ジョイスティックのスティック操作(axis)
"a0", "a1", "a2"...


type: "enable"    キー変換の有効・無効の切り替え(無効でもenableだけは有効)

type: "key"       キークリック
    key:      クリックするキー文字もしくはキー文字列

type: "text"      文字列送信
    str:      送信する文字列

type: "mouse"     マウスボタン操作
    mtype:    マウスボタンの操作タイプ("ckick", "toggle", "double")
    button:   クリックするマウスボタン("left", "middle", "right")

type: "move"      マウス移動
    mx:       x軸移動量
    my:       y軸移動量

type: "scroll"    スクロール
    mx:       x軸スクロール量
    my:       y軸スクロール量

type: "updown"    上下カーソルキー
    mv:       0以上:そのまま 0未満:反転 

type: "leftright" 左右カーソルキー
    mv:       0以上:そのまま 0未満:反転 

*/
var joytable = {
    "0": { type: "mouse", mtype: "click", button: "right" },
    "1": { type: "mouse", mtype: "click", button: "left" },
    "2": { type: "toggle", key: "left" },
    "a0": { type: "move", mx: 10, my: 0 },
    "a1": { type: "move", mx: 0, my: 10 },
    "a3": { type: "scroll", mx: 0, my: 5 },
    "a4": { type: "leftright", mv: 0},
    "a5": { type: "updown", mv: 0},
};

//toggle用にマウスボタンの状態を記憶(trueが押されている状態)
var mousestate = {
    "left": false,
    "right": false,
    "middle": false
};

//キーマウス動作が有効かどうか
var enable = true;

//定義を元にキーやマウスの動作を実行
var sendkey = function (dat, mul) {
    if (dat == null || dat.type == null) return;

    mul = mul < 0 ? -1 : 1;

    if (dat.type === "enable") {
        //キー変換有効無効の切り替え
        enable = enable ? false : true;
    } else if (enable && dat.type === "key") {
        //キー送信
        if (dat.key == null) return;
        robot.keyTap(dat.key);
    } else if (enable && dat.type === "text") {
        //文字列送信
        if (dat.str == null) return;
        robot.typeString(dat.str);
    } else if (enable && dat.type === "mouse") {
        if (dat.mtype === "click") {
            //マウスボタンクリック
            if (dat.button == null) return;
            robot.mouseClick(dat.button);
        } else if (dat.mtype === "double") {
            //マウスボタンダブルクリック
            if (dat.button == null) return;
            robot.mouseClick(dat.button, true);
        } else if (dat.mtype === "toggle") {
            //マウスボタン押下状態切り替え
            if (dat.button == null || mousestate[dat.button] == null) return;
            robot.mouseToggle(mousestate[dat.button] ? "up" : "down", dat.button);
            mousestate[dat.button] = mousestate[dat.button] ? false : true;
        }
    } else if (enable && dat.type === "move") {
        //マウスポインタ移動
        if (dat.mx == null || dat.my == null) return;
        var pos = robot.getMousePos();
        robot.moveMouseSmooth(pos.x + (dat.mx * mul), pos.y + (dat.my * mul));
    } else if (enable && dat.type === "scroll") {
        //スクロール
        if (dat.mx == null || dat.my == null) return;
        robot.scrollMouse(dat.mx * mul, dat.my * mul);
    } else if (enable && dat.type === "updown") {
        //上下カーソルキー送信
        if (dat.mv == null) return;
        dat.mv = dat.mv < 0 ? -1 : 1;
        robot.keyTap(dat.mv * mul < 0 ? "up" : "down");
    } else if (enable && dat.type === "leftright") {
        //左右カーソルキー送信
        if (dat.mv == null) return;
        dat.mv = dat.mv < 0 ? -1 : 1;
        robot.keyTap(dat.mv * mul < 0 ? "left" : "right");
    }
};

//ジョイスティック接続待ち用のsleep
var sleep = function (waitSec) {
    return new Promise(function (resolve) {
        setTimeout(function () { resolve() }, waitSec);
    });
};

//joystickイベントの定義
var init = function (joy) {
    //ボタンクリックイベント
    joy.on('button', button => {
        if (consolelog) console.log({ button });
        if (button.value != 0) {
            sendkey(joytable[button.number], 0);
        }
    });

    //axis移動イベント
    joy.on('axis', axis => {
        if (consolelog) console.log({ axis });
        if (axis.value != 0) {
            sendkey(joytable["a" + axis.number], axis.value);
        }
    });

    //エラーイベント
    joy.on('error', async (err) => {
        if (consolelog) console.log({ err });
        //エラーが発生したら/dev/input/js?の存在を1秒ごとに確認して存在すれば再度joystickオブジェクトを作成
        while (1) {
            if (fs.existsSync('/dev/input/js' + jid)) break;
            await sleep(1000);
        }
        joys = new joystick(jid);
        init(joys)
    });
};

//開始
init(joys);

キーリピート対応版

このまんまだとキーリピートができないのでちょっと使いにくいが、対応しようと思うと根本的に作り直す感じになるっぽいので保留・・・
こっちの方もさくっと作ってみた。

joykeyrepeat.js
const fs = require('fs');
const joystick = require('joystick');
const robot = require('robotjs');

var joys = new joystick(jid);

//コンソールにジョイスティックイベントの内容を表示
//var consolelog = true;
var consolelog = false;

//ジョイスティックのID(通常は0)
var jid = 0;

//キーやスティックが押されたときの動作を定義
/*
ジョイスティックのボタン押下(button)
"0", "1", "2"....

ジョイスティックのスティック操作(axis)
"a0", "a1", "a2"...


type: "enable"    キー変換の有効・無効の切り替え(無効でもenableだけは有効)
    repeat:   キーリピート(true:有効, false:無効)

type: "key"       キークリック
    key:      クリックするキー文字もしくはキー文字列
    repeat:   キーリピート(true:有効, false:無効)

type: "text"      文字列送信
    str:      送信する文字列
    repeat:   キーリピート(true:有効, false:無効)

type: "mouse"     マウスボタン操作
    mtype:    マウスボタンの操作タイプ("ckick", "toggle", "double")
    button:   クリックするマウスボタン("left", "middle", "right")
    repeat:   キーリピート(true:有効, false:無効)

type: "move"      マウス移動
    mx:       x軸移動量
    my:       y軸移動量
    repeat:   キーリピート(true:有効, false:無効)

type: "scroll"    スクロール
    mx:       x軸スクロール量
    my:       y軸スクロール量
    repeat:   キーリピート(true:有効, false:無効)

type: "updown"    上下カーソルキー
    mv:       0以上:そのまま 0未満:反転 
    repeat:   キーリピート(true:有効, false:無効)

type: "leftright" 左右カーソルキー
    mv:       0以上:そのまま 0未満:反転 
    repeat:   キーリピート(true:有効, false:無効)

*/
var joytable = {
    "0": { type: "mouse", mtype: "click", button: "right", repeat: false},
    "1": { type: "mouse", mtype: "click", button: "left", repeat: false},
    "2": { type: "toggle", key: "left", repeat: false},
    "a0": { type: "move", mx: 10, my: 0, repeat: true},
    "a1": { type: "move", mx: 0, my: 10, repeat: true},
    "a3": { type: "scroll", mx: 0, my: 5, repeat: true},
    "a4": { type: "leftright", mv: 0, repeat: true},
    "a5": { type: "updown", mv: 0, repeat: true},
};

//ジョイスティックの状態を記憶
var joystate = {
    "0": 0, "1": 0, "2": 0, "3": 0, "4": 0,
    "5": 0, "6": 0, "7": 0, "8": 0, "9": 0,
    "10": 0, "11": 0, "12": 0, "13": 0, "14": 0,
    "15": 0, "16": 0, "17": 0, "18": 0, "19": 0,
    "a0": 0, "a1": 0, "a2": 0, "a3": 0, "a4": 0,
    "a5": 0, "a6": 0, "a7": 0, "a8": 0, "a9": 0,
};

var joyenable = false;

//toggle用にマウスボタンの状態を記憶(trueが押されている状態)
var mousestate = {
    "left": false,
    "right": false,
    "middle": false
};

//キーマウス動作が有効かどうか
var enable = true;

//定義を元にキーやマウスの動作を実行
var sendkey = function (dat, mul) {
    if (dat == null || dat.type == null) return;

    mul = mul < 0 ? -1 : 1;

    if (dat.type === "enable") {
        //キー変換有効無効の切り替え
        enable = enable ? false : true;
    } else if (enable && dat.type === "key") {
        //キー送信
        if (dat.key == null) return;
        robot.keyTap(dat.key);
    } else if (enable && dat.type === "text") {
        //文字列送信
        if (dat.str == null) return;
        robot.typeString(dat.str);
    } else if (enable && dat.type === "mouse") {
        if (dat.mtype === "click") {
            //マウスボタンクリック
            if (dat.button == null) return;
            robot.mouseClick(dat.button);
        } else if (dat.mtype === "double") {
            //マウスボタンダブルクリック
            if (dat.button == null) return;
            robot.mouseClick(dat.button, true);
        } else if (dat.mtype === "toggle") {
            //マウスボタン押下状態切り替え
            if (dat.button == null || mousestate[dat.button] == null) return;
            robot.mouseToggle(mousestate[dat.button] ? "up" : "down", dat.button);
            mousestate[dat.button] = mousestate[dat.button] ? false : true;
        }
    } else if (enable && dat.type === "move") {
        //マウスポインタ移動
        if (dat.mx == null || dat.my == null) return;
        var pos = robot.getMousePos();
        robot.moveMouseSmooth(pos.x + (dat.mx * mul), pos.y + (dat.my * mul));
    } else if (enable && dat.type === "scroll") {
        //スクロール
        if (dat.mx == null || dat.my == null) return;
        robot.scrollMouse(dat.mx * mul, dat.my * mul);
    } else if (enable && dat.type === "updown") {
        //上下カーソルキー送信
        if (dat.mv == null) return;
        dat.mv = dat.mv < 0 ? -1 : 1;
        robot.keyTap(dat.mv * mul < 0 ? "up" : "down");
    } else if (enable && dat.type === "leftright") {
        //左右カーソルキー送信
        if (dat.mv == null) return;
        dat.mv = dat.mv < 0 ? -1 : 1;
        robot.keyTap(dat.mv * mul < 0 ? "left" : "right");
    }
};

//ジョイスティック接続待ち、ループ用のsleep
var sleep = function (waitSec) {
    return new Promise(function (resolve) {
        setTimeout(function () { resolve() }, waitSec);
    });
};

//joystickイベントの定義
var init = function (joy) {
    //ボタンクリックイベント
    joy.on('button', button => {
        if (consolelog) console.log({ button });
        joystate[button.number] = button.value;
    });

    //axis移動イベント
    joy.on('axis', axis => {
        if (consolelog) console.log({ axis });
        joystate['a' + axis.number] = axis.value;
    });

    //エラーイベント
    joy.on('error', async (err) => {
        if (consolelog) console.log({ err });
        //エラーが発生したら/dev/input/js?の存在を1秒ごとに確認して存在すれば再度joystickオブジェクトを作成
        joyenable = false;
        while (1) {
            if (fs.existsSync('/dev/input/js' + jid)) break;
            await sleep(1000);
        }
        joys = new joystick(jid);
        init(joys)
    });
    joyenable = true;
};

//ループ
var start = async function () {
    while(1) {
        if(joyenable) {
            for(var key in joystate) {
                if(joystate[key] != 0){
                    sendkey(joytable[key], joystate[key]);
                    if(!joytable[key].repeat) joystate[key] = 0;
                }
            }
        }
        await sleep(16);
    }
}

//開始
init(joys);
start();
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Node.js] 新規アプリケーションの製作方法

環境構築をする

  1. nodebrewをインストールする
  2. Node.jsをインストールする

詳しくは以下でまとめています。
https://qiita.com/momo1010/items/dab9c70bfe84a78f23e2

好きな場所にアプリケーション用のディレクトリを作成する

アプリケーション用のディレクトリの作成と移動のコマンドを実行します。

$ mkdir sample
$ cd sample

エディタを起動して先程作成したフォルダを開く

今回エディタは Visual Studio Code を使用します。
Open Folder... から先程作成したフォルダを選択します。

パッケージをインストールする

以下のコマンドを実行すると、 npm の設定ファイルである package.json が生成されます。package.json には、 npmパッケージの設定情報などが書き込まれています。

$ npm init --yes

次に、npmパッケージのインストールをします。今回は、 expressejs をインストールします。

$ npm install express ejs

nodemonをインストールする

jsファイルの変更を反映するには、毎回サーバーを再起動する必要があります。その作業を省くために nodemon という、ファイル更新時に自動でサーバーが再起動するようになるnpmパッケージをインストールします。

$ npm install -g nodemon

サーバーを起動してページを表示する

Visual Studio Code で必要なファイルを作成します。
node_modules フォルダと package-lock.json package.json ファイルがnpmパッケージのインストールをしたときに既に生成されています。

app.js ファイルを作成する

追加で app.js ファイルを作成します。 app.js ファイルに以下のコードをコピーして、貼り付けてください。

const express = require('express');
const app = express();

//CSSや画像ファイルを置くフォルダ(public)を指定するコード
app.use(express.static('public'));

app.get('/', (req, res) => {
  res.render('hello.ejs');
});

app.listen(3000);

views フォルダを作成する

views フォルダを作成し、 hello.ejs ファイルに以下のコードをコピーして、貼り付けてください。

<h1>Hello World</h1>

public フォルダを作成する

public フォルダの中に css フォルダや images フォルダを作成します。

サーバーを起動する

nodemonを使ってサーバーを起動していきます。

$ nodemon app.js

ブラウザを開いて localhost:3000 というURLにアクセスして Hello World と表示されれば成功です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Robocode】JavaScriptを勉強しながら戦車ゲームを攻略していく その1

はじめに

前回記事で構築したRobocodeを遊びながら攻略していきます。
https://qiita.com/abemaki/items/54712e50e4a4a25c229b

ゲームを起動すると以下のような画面が出てきます。
初期段階でもステージ0であれば、五分五分の戦いです。
今回は軽く触りながら、ステージ0での勝率を高めていきたいと思います。

反響があればGithubに改良版をアップしたり続編を書いてみようかな。

 
画面イメージと画面の見方
image.png

ソースコードを理解する

公式の説明を読む前にちょっとだけソースコードを見ていきます

ソースコードを見ていくと
boss-.jsといたファイルとmyrobot.jsといったファイルがあるのがわかります。
myrobot.jsは自機で、boss-
.jsは敵機を表しているものであることがファイル名からわかると思います。
ファイルを開いてみると、何やらファンクションがずらりと並んでいるのがわかりますがこのままだと詳細がよくわかりません。
この理解のまま先には進めません。
image.png

公式の説明を読んでみる

https://github.com/youchenlee/robocode-js

公式のロボットHOWTOを見てみると以下のことがわかります

入手可能な情報:

自己情報
・ me.id
・ me.x
・ me.y
・ me.hp
・ me.angle-現在の角度(タンク角度+タレット角度)
・ me.tank_angle
・ me.turret_angle

敵情報
・ enemy-spot [N] .id
・ enemy-spot [N] .hp
・ enemy-spot [N] .angle-敵に対する角度(方向)

シーケンシャルアクション:
・turn_left(角度)
・turn_right(角度)
・move_forwards(距離)
・move_backwards(距離)
・move_opposide(distance)-このアクションはOnWallCollide()でのみ使用できます

並列アクション:
・turn_turret_left(角度)
・turn_turret_right(角度)
・シュート()
・yell(メッセージ)
 
イベント:
OnIdle()-アイドル時にトリガーされます(実装する必要があります)
OnWallCollide()-タンクが壁に衝突したとき
OnHit()-弾丸が当たったとき
OnEnemySpot()-砲塔が敵に直接面している場合(発砲しない理由はないようです!)

 
 

試しに自機と敵機のセリフを変えてみます。

 
セリフはyell(メッセージ)で定義します。 
ファイルを変更したら保存して、ブラウザを更新しましょう
image.png

 

・・・超賑やかになった。
robo.gif

ファイルに変更を加えるとしっかりと結果に反映されるのがわかります。

 
 
 

ソースコードを改良して、勝率100%を目指してみる。

現状の勝率が何故5割程度なのか整理してみる

両者同じような動きをしていて
クルクル回って、相手を見つけたら弾を発射するだけなので、そのあと発見場所から近い場所を探す等の動作をせずに
またひたすらクルクル相手を探しはじめます。 その為、五分五分の戦いをしていることがわかります。

・自分の動き
 右に動き回りながら相手を探して見つけたら弾を発射するだけ 
 上記を繰り返す

・相手の動き
 クルクル砲台を回転させながら相手を探して見つけたら弾を発射するだけ
 上記を繰り返す

 
 
 

敵に対面した後の動作を変更してみる

そこで小刻みに右に動き回って
相手を見つけたら、できるだけ近い場所で相手を左利回りに探して
攻撃をしかけるようにソースを修正してみようかと思います。
 

小刻みに敵を探して、敵を発見したらできるだけ近い場所を探して敵を攻撃するように修正してみます。
 
対面フラグを追加してアイドル時の処理を以下のように分岐させます。

■アイドル時
対面フラグ=敵を未発見(false)の際の処理:
 小刻みに右回りで敵を探す

対面フラグ=敵を発見(true)の際の処理:
 小刻みに左回りで敵を探す
 この際に敵を発見できなかった場合に対面フラグを対面フラグを敵を未発見(false)に変更します

■敵発見時
 攻撃をしかけて
 対面フラグを敵を発見(true)に変更します

修正前後の自機のソースコード(myrobot.js)

 

修正前のmyrobot.js(クリックして展開)

// Generated by LiveScript 1.2.0
(function(){
  var MyRobot, tr;
  importScripts('../base-robot.js');
  MyRobot = (function(superclass){
    var prototype = extend$((import$(MyRobot, superclass).displayName = 'MyRobot', MyRobot), superclass).prototype, constructor = MyRobot;
    prototype.onIdle = function(){
      this.move_forwards(50);
      this.turn_turret_left(10);
      this.turn_right(90);
    };
    prototype.onWallCollide = function(){
      this.move_opposide(10);
      this.turn_left(90);
    };
    prototype.onHit = function(){
      this.yell("Oops!");
    };
    prototype.onEnemySpot = function(){
      this.yell("Fire!");
      this.shoot();
    };
    function MyRobot(){
      MyRobot.superclass.apply(this, arguments);
    }
    return MyRobot;
  }(BaseRobot));
  tr = new MyRobot("MyRobot");
  function extend$(sub, sup){
    function fun(){} fun.prototype = (sub.superclass = sup).prototype;
    (sub.prototype = new fun).constructor = sub;
    if (typeof sup.extended == 'function') sup.extended(sub);
    return sub;
  }
  function import$(obj, src){
    var own = {}.hasOwnProperty;
    for (var key in src) if (own.call(src, key)) obj[key] = src[key];
    return obj;
  }
}).call(this);


修正後のmyrobot.js(クリックして展開)
// Generated by LiveScript 1.2.0
(function(){
  var MyRobot, tr;
  importScripts('../base-robot.js');
  MyRobot = (function(superclass){
    var discoveryFired,prototype = extend$((import$(MyRobot, superclass).displayName = 'MyRobot', MyRobot), superclass).prototype, constructor = MyRobot;

    // 対面フラグ
    discoveryFired = false;

    // アイドル時にトリガーされます(実装する必要があります)
    prototype.onIdle = function(){
      if(this.discoveryFired){
        // 対面後の動作 小刻みに左に動いで相手を探す

        // 前進(距離)
        this.move_forwards(10);
        // 砲塔を左に向ける(角度)
        this.turn_turret_left(10);
        // 左に曲がる(角度)
        this.turn_left(10);

        // 対面フラグリセット
        this.discoveryFired = false;

      } else {
        // 対面前の動作 小刻みに右に動いで相手を探す

        // 前進(距離)
        this.move_forwards(10);
        // 砲塔を右に向ける(角度)
        this.turn_turret_right(10);
        // 右に曲がる(角度)
        this.turn_right(10);
      }
    };
    // タンクが壁に衝突したとき
    prototype.onWallCollide = function(){
      // 反対に移動(距離)
      this.move_opposide(10);
      // 左に曲がる(角度)
      this.turn_left(90);
    };
    // 自機に弾丸が当たったとき
    prototype.onHit = function(){
      // セリフ
      this.yell("痛いっ");
    };
    // 砲塔が敵に直接面している場合(発砲しない理由はないようです!)
    prototype.onEnemySpot = function(){
      // セリフ
      this.yell("当たれ~っ");
      // 発射
      this.shoot();

      // 対面フラグを立てる
      this.discoveryFired = true;
    };
    function MyRobot(){
      MyRobot.superclass.apply(this, arguments);
    }
    return MyRobot;
  }(BaseRobot));
  tr = new MyRobot("MyRobot");
  function extend$(sub, sup){
    function fun(){} fun.prototype = (sub.superclass = sup).prototype;
    (sub.prototype = new fun).constructor = sub;
    if (typeof sup.extended == 'function') sup.extended(sub);
    return sub;
  }
  function import$(obj, src){
    var own = {}.hasOwnProperty;
    for (var key in src) if (own.call(src, key)) obj[key] = src[key];
    return obj;
  }
}).call(this);

 

 

改良した戦車で闘ってみる

いざっ 勝負!!!
robo3.gif

 
圧勝です。 このステージに関しては勝率100%です。

遊びながらちゃんとエンジニアの基礎中の基礎が経験できるような仕組みになっているのは面白いですね。

・既存ソースコードを見て把握する
・公式ドキュメントを読んで理解を深める
・既存ソースコードに公式ドキュメントで得た理解をコメント文に落とし込んでみる
・現状の動きから課題点を洗い出す
・課題点を改善する方法を考える
・課題点を改善する
・動作確認して結果に超自己満足(←超大事)

ちなみに今回の改良でステージ0は余裕ですが以降はステージが進むにつれて勝率が下がっていきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

javascriptによる並列処理

概要

普段javascriptを書いているとよく使う非同期処理(Promise)だが、言語仕様上(
シングルスレッドのため)非同期処理は処理の順番を変えているだけで厳密には同期処理のようだった。

ざっくり図

同期処理
実行→結果→実行→結果...

非同期処理
実行→実行→結果→結果...

並列処理
実行→結果...
実行→結果...

よくよく調べているとWorkerオブジェクトを使用すれば並列処理ができるみたいなので試してみた

Document
- Docs Worker

ブラウサでの使用

※メインスレッド以外では、domの更新などができない

index.js
var worker = new Worker("./greeting-worker.js");

// データが送られてきたら発火
worker.onmessage = function (message) {
  console.log(message)
  // workerの停止
  this.terminate();
};

// Worker作成時かWorker実行中でエラーとなった場合に発火
worker.onerror = function (err) {
  console.error("onerror", err.message);
};

// データ送信
worker.postMessage("Hi");
greeting-worker.js
// worker.postMessageが呼ばれたら発火
self.onmessage = function (message) {
  // 送られてきたデータ
  console.log(message);
  // workerにデータを送る
  self.postMessage("I'm good.");
};

バックエンド(Node.js)での使用

index.js
var { Worker } = require("worker_threads");
var worker = new Worker("./greeting-worker.js");
// データが送られてきたら発火
worker.on("message", function (message) {
  console.log(message);
  // workerの停止
  worker.terminate();
});

// Worker作成時かWorker実行中でエラーとなった場合に発火
worker.on("error", function (err) {
  console.error("onerror", err.message);
  // workerの停止
  worker.terminate();
});

// workerが停止したら発火
worker.on("exit", function () {
  console.log("Bye!!");
});

// データ送信
worker.postMessage("Hi, how are you?");
greeting-worker.js
// worker.postMessageが呼ばれたら発火
self.onmessage = function (message) {
  // 送られてきたデータ
  console.log(message);
  // workerにデータを送る
  self.postMessage("I'm good.");
};
// スレッドを増やしたい場合
var worker1 = new Worker("./worker1.js")
var worker2 = new Worker("./worker2.js")
var worker3 = new Worker("./worker3.js")
...

感想

あまり並列にすることもない処理でしたが、意外と簡単に扱うことができました。
使い所としては、処理が重い計算処理などやindexedDB(ブラウザ)があるそうです。
またワーカースレッドでデータをやりとりできるMessageChannelというのもありました。(他ArrayBufferやFileHandleというものもあった)

まとめ

  • javascriptは非同期処理の他に並列処理もできる
  • ブラウザ/Node.jsともにAPIが用意されている.(使用感もほぼ同じ)
  • メインスレッド以外では、domの更新などができない
  • ワーカースレッドを増やしたい場合は複数Workerを呼び出す
  • ワーカースレッド間でのデータのやりとりはMessageChannelを使用して行うことができる。 
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsのプロファイリングを試してみる

PM2のプロファイリング周りの調査結果とNode.jsでプロファイリングを行ってみた結果です。(PM2のinstancesの値の変更時の確認方法 - Qiitaの続き)

PM2 Pricingについて

PM2 PM2 PM2 Plus PM2 Enteprise
ゼロダウンタイムリロード
ターミナルベースのモニタリング(pm2 monit)
... ... ... ...
CPUプロファイリング

PM2 - Pricing

PM2 PlusでもCPUプロファイリング可能?

PM2 PlusでもCPUプロファイリングができるみたいなドキュメントが存在しますが、Enterprise限定のようです。

Memory & CPU Profiling | Guide | PM2 Plus Documentation · Issue #212 · keymetrics/doc-pm2 · GitHub

Node.jsでプロファイリングを行う

PlusでCPUプロファイリングできるなら課金しようと思いましたが、Enterpriseの契約はめんどくさそうなので今回はPM2を使わずにプロファイリングを行います。

今回使用したコードはこちら
app.js
const express = require("express");
const crypto = require("crypto");
const app = express();

server = app.listen(3000, function(){
    console.log("Node.js is listening to PORT:" + server.address().port);
});

let users = [];

app.get('/newUser', (req, res) => {
    let username = req.query.username || '';
    const password = req.query.password || '';

    username = username.replace(/[!@#$%^&*]/g, '');

    if (!username || !password || users.username) {
        return res.sendStatus(400);
    }

    const salt = crypto.randomBytes(128).toString('base64');
    const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');

    users[username] = { salt, hash };

    res.sendStatus(200);
});

app.get('/auth', (req, res) => {
    let username = req.query.username || '';
    const password = req.query.password || '';

    username = username.replace(/[!@#$%^&*]/g, '');

    if (!username || !password || !users[username]) {
        return res.sendStatus(400);
    }

    const { salt, hash } = users[username];
    const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');

    if (crypto.timingSafeEqual(hash, encryptHash)) {
        res.sendStatus(200);
    } else {
        res.sendStatus(401);
    }
});

app.get('/auth2', (req, res) => {
    let username = req.query.username || '';
    const password = req.query.password || '';

    username = username.replace(/[!@#$%^&*]/g, '');

    if (!username || !password || !users[username]) {
        return res.sendStatus(400);
    }

    crypto.pbkdf2(password, users[username].salt, 10000, 512, 'sha512', (err, hash) => {
        if (users[username].hash.toString() === hash.toString()) {
            res.sendStatus(200);
        } else {
            res.sendStatus(401);
        }
    });
});

参考:Node.js アプリケーションの簡単なプロファイリング | Node.js

# ApacheBenchを利用するためhttpdをインストール
yum install httpd -y
# 今回PM2は使わないことにしたのでstopしておく
pm2 stop test-app
node --prof app.js
# ユーザー作成
curl -X GET "http://localhost:3000/newUser?username=matt&password=password"
# ベンチマークテスト
ab -k -c 20 -n 250 "http://localhost:3000/auth?username=matt&password=password"

ベンチマーク結果
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Finished 250 requests


Server Software:        
Server Hostname:        localhost
Server Port:            3000

Document Path:          /auth?username=matt&password=password
Document Length:        2 bytes

Concurrency Level:      20
Time taken for tests:   20.130 seconds
Complete requests:      250
Failed requests:        0
Keep-Alive requests:    250
Total transferred:      57250 bytes
HTML transferred:       500 bytes
Requests per second:    12.42 [#/sec] (mean)
Time per request:       1610.378 [ms] (mean)
Time per request:       80.519 [ms] (mean, across all concurrent requests)
Transfer rate:          2.78 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:    81 1543 263.8   1610    1697
Waiting:       78 1543 263.9   1610    1697
Total:         81 1543 263.7   1610    1697

Percentage of the requests served within a certain time (ms)
  50%   1610
  66%   1612
  75%   1612
  80%   1613
  90%   1614
  95%   1614
  98%   1615
  99%   1615
 100%   1697 (longest request)

# 人が読みやすいファイルに変換
node --prof-process isolate-0x3d49da0-16098-v8.log > processed.txt
cat processed.txt

同期バージョンのpbkdf2関数を使用した後のプロファイリング結果。

processed.txt
...

 [Bottom up (heavy) profile]:
  Note: percentage shows a share of a particular caller in the total
  amount of its parent calls.
  Callers occupying less than 1.0% are not shown.

   ticks parent  name
  56011   75.3%  __GI_epoll_pwait

  18023   24.2%  node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
  18023  100.0%    v8::internal::Builtin_HandleApiCall(int, unsigned long*, v8::internal::Isolate*)
  18023  100.0%      LazyCompile: ~pbkdf2Sync internal/crypto/pbkdf2.js:45:20
  17951   99.6%        LazyCompile: ~<anonymous> /var/www/test/app.js:29:18
  17951  100.0%          LazyCompile: ~handle /var/www/test/node_modules/express/lib/router/layer.js:86:49
  17951  100.0%            LazyCompile: ~next /var/www/test/node_modules/express/lib/router/route.js:114:16

コード改善後のプロファイリング結果

ベンチマーク結果は差がありませんでした。(Nodeのバージョンがv12.20.0


変更後のベンチマーク結果
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Finished 250 requests


Server Software:        
Server Hostname:        localhost
Server Port:            3000

Document Path:          /auth?username=matt&password=password
Document Length:        2 bytes

Concurrency Level:      20
Time taken for tests:   20.287 seconds
Complete requests:      250
Failed requests:        0
Keep-Alive requests:    250
Total transferred:      57250 bytes
HTML transferred:       500 bytes
Requests per second:    12.32 [#/sec] (mean)
Time per request:       1622.975 [ms] (mean)
Time per request:       81.149 [ms] (mean, across all concurrent requests)
Transfer rate:          2.76 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       0
Processing:    86 1564 237.9   1619    1657
Waiting:       82 1564 238.0   1619    1657
Total:         86 1564 237.8   1619    1657

Percentage of the requests served within a certain time (ms)
  50%   1619
  66%   1629
  75%   1634
  80%   1635
  90%   1643
  95%   1647
  98%   1656
  99%   1657
 100%   1657 (longest request)


processed.txt
...
 [Bottom up (heavy) profile]:
  Note: percentage shows a share of a particular caller in the total
  amount of its parent calls.
  Callers occupying less than 1.0% are not shown.

   ticks parent  name
  94151   98.9%  __GI_epoll_pwait

Apache Benchの引数を変更

# 改善前のコード、同時接続200、合計250リクエスト
 ab -k -c 200 -n 250 "http://localhost:3000/auth?username=matt&password=password"
> apr_socket_recv: Connection reset by peer (104)
# 改善後のコード、同時接続200、合計250リクエスト
 ab -k -c 200 -n 250 "http://localhost:3000/auth2?username=matt&password=password"

同時接続20であればパフォーマンスに影響はありませんでしたが、200にしたところ改善前のコードではapr_socket_recv: Connection reset by peer (104)が返ってきました。また、同条件でpm2で起動した場合は改善前のコードでも正常に実行されました。(PM2のプロセス数を見直す際にはApache Benchでテストしてみる)

参考:Apache Benchでサクッと性能テスト - Qiita

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsを勉強する② - テキストファイルの作成方法

はじめに

前回は、Node.jsの環境構築についてまとめました。今回はテキストファイルの作成方法を記事にします。

教材

Udemy
The Complete Node.js Developer Course (3rd Edition)
https://www.udemy.com/course/the-complete-nodejs-developer-course-2/

Jsファイルの作成とファイルモジュールの導入

まずは、コードを書くファイルを作成します。今回はnodeapp.jsと名付けます。
次に、app.jsの中にrequireを用いファイルを操作するモジュールを導入します。
constを用いて変数fsを定義し、モジュールの中身を代入します。

nodeapp.js
const fs = require('fs')

テキストファイルを作成する

次に、"writeFileSync"メソッドを用いて、テキストファイルを作成し、文字も入れます。
カッコの中の一つ目の要素はファイル名、2つ目の要素は、書く文字になります。

nodeapp.js
//ファイルモジュールを導入
const fs = require('fs')
//ファイルを作成し、文字を書く
fs.writeFileSync('notes.txt', 'Hello, this is the first message!')

実行するにはターミナルにnode ファイル名と入力します。

ターミナル
node nodeapp.js

文字を追加する

文字を追加するには、appendFileSyncメソッドを使います。
カッコの中の一つ目が

nodeapp.js
//ファイルを作成し、文字を追加する
fs.appendFileSync('notes.txt', 'Hello, this is the first message!')

再度実行して、動作を確認しましょう

ターミナル
node nodeapp.js

テキストファイルを確認して、文字が追加されていれば、完成です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsを勉強する① - 開発環境構築

はじめに

ずっと勉強したいと思っていたNode.js
Udemyの講座で勉強してみました。

備忘録として学んだことを自分なりにまとめて記しておきます。
なお、今回は英語の講座でしたが、英語も勉強できて一石二鳥ですね。

今回は開発環境構築を記事にします。

教材

Udemy
The Complete Node.js Developer Course (3rd Edition)
https://www.udemy.com/course/the-complete-nodejs-developer-course-2/

Node.jsのインストール

公式ホームページ(nodejs.org)に行き、最新のNode.jsをダウンロードします
Node-js.png

カスタマイズする必要はないので、インストーラーは"Next"をclickして進め、インストールを完了します。

ダウンロードされているかチェック

コマンドプロンプトを起動して、node -vでインストールされたバージョンを確認

node -v.png

コードのエディタがない場合は、ローカルにインストール

講座では、Visual Studio Code を使用しています。
個人的にatomの方が馴染みがありますが、Visual Studio Codeも使いやすかったです。

ダウンロードはこちらから。(https://code.visualstudio.com)
Visual-Studio-Code-Code-Editing-Redefined.png

コードを書いてみる

まずは任意のフォルダーをデスクトップにつくり、それをVisual Studio Codeで開きます。
左上にフォルダーが表示されます。
Screenshot 2021-01-01 143836.png
さらに一番左のNew Fileボタンを押して、nodeapp.jsという名前のファイルを作って下さい。
Screenshot 2021-01-01 144049.png

作ったファイルの中に、console. logを用いてメッセージが表示されるコードを書きます。

nodeapp.js
console.log("Hello World!")

Terminal >> New Terminalをクリックし、ターミナルを開いて、コードを実行してみます。
Screenshot 2021-01-01 144654.png

ターミナル
node nodeapp.js
Hello World!

nodeapp.jsでconsole.logの中に設定したメッセージが表示されれば、完成です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ElectronアプリでKeycloakと連携(3. ログイン機能導入編)

背景

今回は、前回作成したelectronのアプリに認証機能を追加してみる。
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc

今回も前回に引き続きAuth0のブログを参考にしてみる。
https://auth0.com/blog/securing-electron-applications-with-openid-connect-and-oauth-2/

※(注意)自学習を目的として書いています。この記事に記載の内容は、あくまで自分(素人)の解釈となります。

electronアプリへの認証機能追加

前回作成したシンプルなelectronアプリにログイン機能、ログアウト機能を追加してみる。

ログイン機能の実装

まずは、単純にkeycloakのログイン画面にリダイレクトして、認証するところまでを実装してみる。

前回作ったmainフォルダの下に今度はauth-process.jsというファイルを置く。

main/auth-process.js
const {BrowserWindow} = require('electron');
const createAppWindow = require('../main/app-process');

let win = null;

function createAuthWindow() {
  destroyAuthWin();

  win = new BrowserWindow({
    width: 1000,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      enableRemoteModule: false
    }
  });

  win.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/auth?' +
              'scope=openid profile offline_access&' +
              'response_type=code&' +
              'client_id=test&' +
              'redirect_uri=http://localhost/callback');

  const {session: {webRequest}} = win.webContents;

  const filter = {
    urls: [
      'http://localhost/callback*'
    ]
  };

  webRequest.onBeforeRequest(filter, async () => {
    // TODO トークン取得
    createAppWindow();
    return destroyAuthWin();
  });

  win.on('closed', () => {
    win = null;
  });
}

function destroyAuthWin() {
  if (!win) return;
  win.close();
  win = null;
}

module.exports = {
  createAuthWindow,
};

createAuthWindow()は、ログインウインドウを生成するfunctionで、最後にexportすることで公開している。

最初にウインドウを破棄するfunctionであるdestroyAuthWin()を一旦呼び出してから生成を開始している。

ホームページ用のウインドウとは異なり、ログインウインドウはセキュリティリスクを軽減するため以下のように設定している。
nodeIntegration: falseは、Node.jsの組み込みを実施しないための設定。
enableRemoteModule: falseは、レンダラープロセスがメインプロセスと通信しないための設定。

その後、win.loadURL()で呼び出すログインページを指定している。
ここは、前々回にkeycloakで設定したレルムにおける認証用のURLを指定しておく。

webRequest.onBeforeRequest()で特定のURLへのリクエストの実行前に実施する処理を書いている。
filterに'http://localhost/callback*'を指定しているので、keycloakでログインした後のリダイレクトのURLにリクエストを送る際に発動する。

処理としては、最初にトークンを取得して、ホームページ用のウインドウを開き、ログイン用のウインドウを閉じる。
ただ、トークンの取得はまだ実装していないので、現時点ではTODOとしておく。

続いて、main.jsを以下のように修正。

main.js
...

const createAppWindow = require('./main/app-process');
const {createAuthWindow} = require('./main/auth-process'); // 追加

async function showWindow() {
  // return createAppWindow(); 一旦コメントアウト
  return createAuthWindow();
}

...

createAppWindow()に代えて、createAuthWindow() を呼び出すようにしてみる。

ちなみに、createAuthWindow() の処理の中でcreateAppWindow()が呼び出される。

一旦ここで実行してみる。

> yarn start

image.png

無事にkeycloakのログイン画面が表示された。

ちなみに、今回は組み込みのBrowserWindowにログイン画面を表示している(Auth0のブログも同様)。この場合、セキュリティ的にはあまりよろしくないという意見もあるらしい。

大きなところでは下記の2つの問題点が挙げられる。

  • 正規のサイトかどうかアプリの利用者には判定できないので、利用者に不安を与える。

  • 表示したログイン画面からいろんなところに遷移できるようになっていると、組み込みのBrowserWindow内で、悪さをされる可能性が出てくる。

一方で、標準ブラウザを利用してログインさせるやり方もあるけど、いきなり別のブラウザが開く挙動は、ユーザービリティ的には微妙な気がする(ログイン後に標準ブラウザのタブを閉じる方法も考える必要がある)。

まぁ、今回は認証サービスも自前で作っていて(そもそもテスト用だけど。。)、googleやtwitterのアカウントと連携しているわけではないので、ユーザービリティ優先ということで。keycloakのログイン画面から他のページに遷移することもできなさそうだし。

ということで、用意しておいたユーザーでログインしてみる。

image.png

ちゃんと前回のホームページが表示された。

ログアウト機能の追加

トークン取得処理を先に実装すべきかもしれないが、「logout」ボタンを押しても何も起こらないのは寂しいので、先にログアウトを実装してみる。

auth-process.jsを以下のように編集。

main/auth-process.js
...

function createLogoutWindow() {
  const logoutWindow = new BrowserWindow({
    show: false,
  });

  logoutWindow.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/logout');

  logoutWindow.on('ready-to-show', async () => {
    logoutWindow.close();
    // TODO クライアント側のログアウト処理
  });
}

module.exports = {
  createAuthWindow,
  createLogoutWindow, // 追加
};

createLogoutWindow()というfunctionを追加してexportしている。

createLogoutWindow()では、非表示のウインドウを開いて、keycloakのlogout用エンドポイントを呼び出しているだけ。

続いて、renderersフォルダの下に、以下のようなhome.jsを追加する。

renderers/home.js
const { remote } = require("electron");
const authProcess = remote.require("./main/auth-process");

document.getElementById("logout").onclick = () => {
  authProcess.createLogoutWindow();
  remote.getCurrentWindow().close();
};

ログアウトボタンが押されたときにログアウト処理が実行されるようにしている。

home.htmlでこのスクリプトファイルを読み込むようにする。

renderers/home.html
<html lang="en">

  ...

  <script src="home.js"></script>

</html>

実行して確認してみる。

> yarn start

image.png

ログアウトボタンを押すと、Electronアプリが閉じることは確認できた。

ただ、本当にログアウトできたか怪しいので、keycloak側で確認してみる。

image.png

ログアウトボタン押下前と押下後でセッションが減っていることが確認できた。

トークンの取得

ここからが本番。

先に「axios」と「jwt-decode」をインストールしておく。

> yarn add axios
> yarn add jwt-decode

今度はservicesというフォルダを作成し、その下にauth-service.jsというファイルを置いて以下のように実装した。

services/auth-service.js
const jwtDecode = require("jwt-decode");
const axios = require('axios');
const crypto = require('crypto');
const url = require('url');

let codeVerifier = null;
let accessToken = null;
let profile = null;

function getAccessToken() {
  return accessToken;
}

function getProfile() {
  return profile;
}

async function loadTokens(callbackURL) {
  const urlParts = url.parse(callbackURL, true);
  const query = urlParts.query;

  var params = new URLSearchParams();
  params.append('grant_type', 'authorization_code');
  params.append('client_id', 'test');
  params.append('code', query.code);
  params.append('redirect_uri', 'http://localhost/callback');
  params.append('code_verifier', codeVerifier);

  try {
    const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params);
    accessToken = response.data.access_token;
    profile = jwtDecode(response.data.id_token);
  } catch (error) {
    // TODO ログアウト
    throw error;
  }
}

function getChallenge() {
  codeVerifier = base64URLEncode(crypto.randomBytes(32));
  return base64URLEncode(sha256(codeVerifier));
}

function sha256(buffer) {
  return crypto.createHash('sha256').update(buffer).digest();
}

function base64URLEncode(str) {
  return str.toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');
}

module.exports = {
  getChallenge,
  getAccessToken,
  getProfile,
  loadTokens,
};

loadTokens()がトークン取得のメインロジック。認証後のリダイレクト時に呼び出せるようにするので、このfunctionはexportしておく。

loadTokens()の中で、トークン取得用のエンドポイントにPOSTリクエストを投げる。

リクエストパラメータにはURLSearchParamsを利用する必要があるので注意。また、PKCEを利用するので、code_verifierも指定している。

取得したaccess_token(accessToken)とid_token(profile)は、メモリ上にキャッシュしておき、getAccessToken()とgetProfile()で外から取得できるようにしておく。

getChallenge()では、最初にcode_verifierで利用する値を生成する。この値を利用して、code_challengeを生成する。

先に生成したcode_verifierの値はloadTokens()の中で、リクエストパラメータに指定するのでメモリ上にキャッシュしておく(codeVerifier)。

次に生成したcode_challengeの値は、keycloakのログイン画面呼び出し時に利用するため、getChallenge()の返り値として取得できるようにし、getChallenge()は、exportしてauth-process.jsから呼び出せるようにしておく。

PKCEの流れは下記のような感じ。

  1. ログイン画面のリクエスト時に指定されたcode_challengeの値を、keycloak側が認可コードに紐づけて保存しておく。
  2. 後のトークン取得リクエスト時に送られてきたcode_verifierを利用して、keycloak側でクライアントと同じロジックでチャレンジを生成。
  3. 1と2のチャレンジコードを比較して、同じクライアントから送られたリクエストであることを検証する。

つまり、認証後のリダイレクトURLに含まれる認可コードを何らかの方法で盗んだとしても、正しいcode_verifierの値を知っていなければ、トークン取得に失敗することになるということらしい。

auth-process.js側の修正は以下のような感じ。

main/auth-process.js
const {BrowserWindow} = require('electron');
const createAppWindow = require('../main/app-process');
const authService = require('../services/auth-service'); // 追加

...

function createAuthWindow() {

  ...

 win.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/auth?' +
              'scope=openid profile offline_access&' +
              'response_type=code&' +
              'client_id=test&' +
              'code_challenge=' + authService.getChallenge() + '&' + // 追加
              'code_challenge_method=S256&' + // 追加
              'redirect_uri=http://localhost/callback');

  ...

  webRequest.onBeforeRequest(filter, async ({url}) => { // パラメータにurlを追加
    await authService.loadTokens(url); // 追加
    createAppWindow();
    return destroyAuthWin();
  });

  ...
}
...

まずは、keycloakのログイン画面呼び出し時のURLのパラメータに「code_challenge」と「code_challenge_method」を追加している。

「code_challenge」の値は、auth-service.jsのgetChallenge()で取得した値を指定する。

「code_challenge_method」はkeycloak側の設定に合わせて「S256」を指定する。

続いて、keycloakのログイン画面で認証した後のリダイレクト時の事前処理で、auth-service.jsのloadTokens()を呼び出し、keycloakから取得した認可コードを利用してアクセストークンを取得する。

その後の、ホームのウインドウを生成し、ログイン用のウインドウを破棄する流れは変更なし。

せっかくなので、取得した情報をホームの画面で表示できるようにしてみる。home.htmlを以下のように修正。

renderers/home.html
  ...

  <body>
    <p>Home</p>
    <div> <!-- ここから追加-->
      <textarea id="token" rows="12" cols="120"></textarea>
      <textarea id="profile" rows="8" cols="120"></textarea>
    </div> <!-- ここまで-->
    <button id="logout">Logout</button>
  </body>

  ...

</html>

tokenとprofileを表示するテキストエリアを追加しただけ。

続いて、home.jsを編集して、auth-service.jsからアクセストークンとプロファイルの情報を取得して、テキストエリアにセットするようにする。

renderers/home.js
const { remote } = require("electron");
const authProcess = remote.require("./main/auth-process");
const authService = remote.require("./services/auth-service"); // 追加

const webContents = remote.getCurrentWebContents(); // 追加

// 追加
webContents.on("dom-ready", () => {
  const token = authService.  getAccessToken();
  const profile = authService.getProfile();
  document.getElementById("token").innerText = token;
  document.getElementById("profile").innerText = JSON.stringify(profile);
});

...

実行して確認してみる。

> yarn start

用意したユーザーでログインすると、下記の画面が表示された。

image.png

無事にアクセストークンとプロファイルが取得できていることが確認できた。

リフレッシュトークンの利用

続いて、リフレッシュトークンを利用する実装を導入してみようと思う。

先に「keytar」をインストールしておく。

> yarn add keytar

「keytar」は「システムのキーチェーンでパスワードを取得、追加、置換、削除するためのネイティブモジュール」とのこと。

アクセストークンはメモリ上に保持しているが、リフレッシュトークンは、一度クライアントを落とした後に再起動しても利用できるように、何らかの形で永続化する必要がある。

ということで、比較的安全に保存するために、システムのキーチェーンを利用する。

「keytar」をインストールしたら、auth-service.jsに下記のようにリフレッシュトークンの取得処理を追加する。

services/auth-service.js
const jwtDecode = require("jwt-decode");
const axios = require('axios');
const keytar = require("keytar"); // 追加
const os = require("os"); // 追加
const crypto = require('crypto');
const url = require('url');

let codeVerifier = null;
let accessToken = null;
let profile = null;
let refreshToken = null; // 追加

...

// function追加
async function refreshTokens() {
  const refreshToken = await keytar.getPassword('electron-openid-test', os.userInfo().username);

  if (refreshToken) {
    var params = new URLSearchParams();
    params.append('grant_type', 'refresh_token');
    params.append('client_id', 'test');
    params.append('refresh_token', refreshToken);

    try {
      const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params);
      accessToken = response.data.access_token;
      profile = jwtDecode(response.data.id_token);
    } catch (error) {
      // TODO ログアウト
      throw error;
    }
  } else {
    throw new Error("No available refresh token.");
  }
}

...

async function loadTokens(callbackURL) {
  ...
  try {
    const response = ...
    ...
    // 追加
    refreshToken = response.data.refresh_token; 
    if (refreshToken) {
      await keytar.setPassword('electron-openid-test', os.userInfo().username, refreshToken);
    }
  } catch (error) {
    ...
  }
}

...

module.exports = {
  ...
  refreshTokens, // 追加
};

refreshTokens()のfunctionを追加して、トークンエンドポイントにトークンのリフレッシュを要求する。

refreshTokenが有効であれば、アクセストークンとプロファイル情報を返してくれる。refreshTokenを取得していない場合や、refreshTokenが無効な場合はエラーをあげる。

refreshTokenは、アクセストークンの取得時に一緒に返してくれているので、loadTokens()の中でメモリ上にキャッシュし、システムのキーチェーンにも保存しておく。

続いて、main.jsの最初でリフレッシュトークンの処理を実行するように、showWindow()の中身を変更する。

main.js
...
const authService = require('./services/auth-service'); // 追加

async function showWindow() {
  try {
    await authService.refreshTokens();
    return createAppWindow();
  } catch (err) {
    createAuthWindow();
  }
}
...

最初にrefreshTokens()を呼び出して、アクセストークンをリフレッシュしてから、ホームのウインドウを開く。

リフレッシュトークンが無効になっている場合などには、エラーがあがってくるので、catchしてログイン処理にまわす。

実行して確認してみると、とりあえず以下の動作を確認できた。

  1. 初回起動
    → ログイン画面が表示されて、ログインするとホームの画面が表示される

  2. 一旦アプリを落として再起動
    → ログイン画面が表示されずにホームの画面が表示される

ただ、ログアウト時にリフレッシュトークンを破棄する処理を実装していないので、このままでは再度ログイン画面を表示させるためには、30日間放置するか、keycloakの管理画面で強制的にログアウトさせる必要がある。

※認証時のリクエストで「scope」に「offline_access」を指定しているため、keycloakの「Offline Session Idle」がタイムアウト時間(デフォルト30日)となる。

ログアウト時のトークン破棄

ということで、ログアウト時にトークンを破棄する実装を入れる。

services/auth-service.js
...

async function refreshTokens() {
  ...
  if (refreshToken) {
    ...
    try {
      ...
    } catch (error) {
      await logout(); // 追加
      throw error;
    }
  } else {
    ...
  }
}

async function loadTokens(callbackURL) {
  ...
  try {
   ...
  } catch (error) {
    await logout(); // 追加
    throw error;
  }
}

...

// 追加
async function logout() {
  await keytar.deletePassword('electron-openid-test', os.userInfo().username);
  accessToken = null;
  profile = null;
  refreshToken = null;
}

module.exports = {
  ...
  logout, // 追加
};

logout()のfunctionを追加して、各種トークンを削除する処理を入れる。また、各種トークン取得処理の失敗時にこれを呼び出すようにする。

明示的にログアウトが指示された場合にもlogout()が呼び出せるように、exortして公開しておく。

続いて、明示的にログアウトが指示された場合の、呼び出し側のコード修正。

auth-process.jsを修正する。

main/auth-process.js
...

function createLogoutWindow() {
  ...

  logoutWindow.on('ready-to-show', async () => {
    logoutWindow.close();
    await authService.logout(); // 追加
  });
}

...

ログアウトが指示されたときに、auth-service.jsのlogout()を呼び出すようにするだけ。

実行して確認してみると、以下のよう感じで意図した動作となった。

image.png

おまけ(Signed JWTを利用したクライアント認証の導入)

ここまでで一通りの実装は出来たはず(取得したアクセストークンは利用してないけど。。)なので、ここからはおまけ。

実は、最初は署名付きのJWTを利用して、クライアント認証を実施しようと考えていた。

keycloakはクライアントの設定で「Access Type」を「Confidential」にすることで、クライアント認証の設定ができるようになる。

で、「Credentials」のタブで「Signed JWT」を選択すると、署名付きのJWTを利用したクライアント認証を実施できるようになっている。

image.png

「Generate new keys and certificate」を押すと、キーペアの生成画面が表示されるので、「PKCS12」を選択し、適当にパスワードを設定して、「Generate and Download」ボタンを押下する。

image.png

「credentials」の画面に戻ると同時に「keystore.p12」がダウンロードされる。

image.png

ここまででkeycloak側の設定は完了なんだけれども、この時点で行き詰まってしまった。

クライアント認証するためには、この「keystore.p12」の秘密鍵を使って、クライアントで生成したJWTに署名を付ける必要があるんだけれども、そのためには、クライアント側で「keystore.p12」を保持する必要がある。

electronアプリのようなクライアントはエンドユーザーの端末にインストールされる感じかと思うので、どう足掻いても秘密鍵を盗まれるリスクは排除できないし、electronアプリに秘密鍵を同梱して配布したら、秘密鍵が悪用された場合などに、簡単に差し替え出来なくなる。

ということで、そもそもelectronアプリのようなクライアントの「Access Type」は「Public」を選ぶべきなんだろうと考え、クライアント認証は諦めた。(いろんな記事を見ても、「public」にして、PKCEで認可コード横取りに対処するのが一般的なんだろうと考えた。)

で今に至る。

一応、署名付きJWTのクライアント実装も試してみたので、載せておく。

services/auth-service.js
...
async function loadTokens(callbackURL) {
  const urlParts = url.parse(callbackURL, true);
  const query = urlParts.query;

  var params = new URLSearchParams();
  params.append('grant_type', 'authorization_code');
  params.append('client_id', 'test');
  params.append('code', query.code);
  params.append('redirect_uri', 'http://localhost/callback');
  params.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
  params.append('client_assertion', generateClientAssertion());

  try {
    const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params);
    accessToken = response.data.access_token;
    profile = jwt.decode(response.data.id_token);
    console.log(profile);
  } catch (error) {
    throw error;
  }
}

function generateClientAssertion() {
  const now = new Date();
  const iatValue = now.getTime();
  now.setMinutes(now.getMinutes() + 1);
  const expValue = now.getTime();
  const payload = {
    aud: 'http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', // トークンエンドポイントのURL
    exp: expValue, // トークンの有効期限
    jti: Math.random().toString(32).substring(2), // ユニークな値(今回はランダム文字列を簡易生成)
    iat: iatValue, // トークンを署名した時刻
    iss: 'http://localhost:3000/', // JWT を署名したクライアントの識別子
    sub: 'test' // keycloakに登録したクライアントID
  };  
  return sign(payload);
}

function sign(payload) {
  const keyFile = fs.readFileSync('keys/keystore.p12'); // ファイル読み込み
  const keyBase64 = keyFile.toString('base64'); // Stringで取得
  const p12Der = forge.util.decode64(keyBase64); // base64からデコード
  const p12Asn1 = forge.asn1.fromDer(p12Der); // ASN.1オブジェクトを取得
  const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, 'test'); // p12として読み込み
  const privateKey = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[forge.pki.oids.pkcs8ShroudedKeyBag][0].key; // 秘密鍵取得
  const rsaPrivateKey = forge.pki.privateKeyToAsn1(privateKey); // RSA秘密鍵に変換
  const privateKeyInfo = forge.pki.wrapRsaPrivateKey(rsaPrivateKey); // PrivateKeyInfoでラップ
  const pemPrivate = forge.pki.privateKeyInfoToPem(privateKeyInfo); // PEM形式に変換
  const signedJwt = jwt.sign(payload, pemPrivate, { algorithm: 'RS256'}); // JWTに署名
  return signedJwt;
}
...

依存として「jsonwebtoken」と「node-forge」をインストールし、auth-service.jsのloadTokens()を上記のように書き換える。さらに「keys」というフォルダの下に「keystore.p12」を置く必要がある。

上記実装で、クライアント認証したうえでトークンが取得できた。

今回は確認のために、electronアプリの実装に埋め込んで試してみたが、もしクライアント認証を実施したいなら、別のやり方を考える必要がある。

例えば、トークンに署名するための専用サーバーを立ち上げて、認可コードを受け取って、署名したJWTを返すようなサービスを作るとか、認可コードを受け取って、署名したJWTを生成しつつトークンエンドポイントへのリクエストを中継するようなサービスを作る感じか。

秘密鍵はサーバー側にあるので、盗まれるリスクが少なく、後から差し替えも可能になる。

ただ、盗まれた認可コードでリクエストが来る可能性があるので、結局、PKCEのような仕組みを導入して、認証時のユーザーと同一かどうかを検証する必要がありそう。

非常にめんどくさいし、やらかしそう。

(2021/01/04 追記:別の記事で無理やり試してみました。)

ということで、特別な事情でもない限りは、「Access Type」は「Public」として実装する方がよいと思われる。

さいごに

3つの記事でに分けて、keycloakと連携してelectronのアプリに認証機能を追加するところまで実施してみました。

基本的にはAuth0のブログを参考にして実装しましたが、一気に理解するのは難しそうだったので、少しずつ実装を追加していく形にしました。おかげで、個人的には理解が深まった(気がする)けど、長編記事になってしまいました。なので、もともと詳しい人は、Auth0のブログを直接見た方がきっと分かりやすいんじゃないかと思います。

ただ、Keycloakの場合はトークン取得時のリクエストの形式(content-type)が違うなど、いくつかAuth0とは違う実装が必要だったので、その辺りの実装が参考になれば幸いです。

過去2回の記事は以下です。

ElectronアプリでKeycloakと連携(1. keycloakの設定編)
https://qiita.com/yusuke-ka/items/69d4146f344a95aa4662

ElectronアプリでKeycloakと連携(2. Electronアプリの作成編)
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ElectronアプリでKeycloakと連携(2. Electronアプリの作成編)

背景

前回、keycloakの設定まで実施した。今回は認証機能を導入するElectronアプリを実装してみる。
https://qiita.com/yusuke-ka/items/69d4146f344a95aa4662

Auth0のブログで公開されているやり方が安全そうな気がするので、ここを参考にしてやってみようと思う。
https://auth0.com/blog/securing-electron-applications-with-openid-connect-and-oauth-2/

といっても、今回は単純なElectronアプリを作るだけで、認証回りは次回。

準備

まずは、プロジェクトの作成から。

> mkdir electron
> cd electron
> yarn init

initはすべてデフォルトのまま。

続いて依存のインストールを実施する。

> yarn add electron

Electronのインストールだけ。少し時間がかかった。

Electronアプリの作成

最初に、アプリのホームページを作ってみる。

renderersというフォルダを作り、その下に以下のようなファイル(home.html)を作成する。

renderers/home.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Electron App</title>
  </head>

  <body>
    <p>Home</p>
    <button id="logout" class="btn">Logout</button>
  </body>

</html>

直接ブラウザでhome.htmlを読み込んだ際の見た目はこんな感じ。

image.png

テスト用なので、Homeという文字とログアウトボタンだけを配置したシンプルなホームページ。
もちろん、ログアウトボタンを押しても何も起こらない。

続いて、このページをElectronで表示するようにしてみる。

今度はmainというフォルダを作成し、その下にapp-process.jsというファイルを置く。

main/app-process.js
const { BrowserWindow } = require("electron");

function createAppWindow() {
  let win = new BrowserWindow({
    width: 1000,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true,
    },
  });

  win.loadFile("./renderers/home.html");

  win.on("closed", () => {
    win = null;
  });
}

module.exports = createAppWindow;

nodeIntegration: trueは、Node.jsの組み込みを実施するための設定。
enableRemoteModule: trueは、レンダラープロセスがメインプロセスと通信するための設定。

createAppWindowというfunctionをexportして公開している。

続いて、ルートフォルダの下に、最初に呼び出されるファイル(main.js)を作成する。

main.js
const {app} = require('electron');

const createAppWindow = require('./main/app-process');

async function showWindow() {
  try {
    return createAppWindow();
  } catch (err) {
    // TODO 認証
  }
}

app.on('ready', showWindow);

app.on('window-all-closed', () => {
  app.quit();
});

今はまだ、先ほどのapp-process.jsが公開しているcreateAppWindowを呼び出すだけの実装。

とりあえず、この状態で動かしてみる。

package.jsonに以下を追記。

package.json
  "scripts": {
    "start": "electron ./main.js"
  }

electronコマンドでmain.jsを呼び出しているだけ。

実行。

> yarn start

image.png

Electronアプリとして、ホームページが表示された。

次回予告

ElectronアプリでKeycloakと連携したログイン機能を実装するために、今回は、確認用のシンプルなElectronアプリの実装をおこなった。

次は、いよいよElectronアプリに認証機能を導入する。
https://qiita.com/yusuke-ka/items/17a5b8fbd544c4c211a3

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ElectronアプリでKeycloakと連携(1. keycloakの設定編)

背景

以前、Nuxt.jsでKeycloakと連携したログイン機能を実装してみた。
https://qiita.com/yusuke-ka/items/1beef8d9e0bbeb052e5a

今度はelectronアプリでKeycloakと連携したログイン機能を実装してみようと思う。

その準備として、今回は、まずkeycloakの設定をやってみる。

keycloakのインストール等は以下で実施したので、これを使う。

https://qiita.com/yusuke-ka/items/1beef8d9e0bbeb052e5a#keycloak%E3%81%AE%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97

keycloakの設定

keycloakの管理UIにアクセスして、keycloakの設定を実施していく。

レルム追加

最初にレルムを追加する。

image.png

「Name」を入力して、「Create」ボタンを押すだけ。

クライアントの追加

続いて、作成したレルムの「Clients」でclientを新規作成。

image.png

右のほうにある「create」ボタンを押す。

image.png

Clientの追加画面が表示されるので、「Client ID」だけ入力して「Save」ボタンを押すと、詳細設定画面に遷移する。

image.png

「Client Protocol」の設定は「openid-connect」のままにする。

electronアプリの場合、クライアント認証は難しそうなので、「Access Type」もとりあえず「public」のままにしておく。
(「confidencial」にすると、Client Secretや秘密鍵をクライアント側で保持する必要がでてくる。electronアプリのように、エンドユーザーのPCにインストールするようなアプリだと、これらを安全に保持するのはとても難しいと思われる。)

「Standard Flow Enabled」が有効になっていることを確認。

electronアプリから利用することを想定しているので、「Valid Redirect URIs」には、とりあえず、「 http://localhost/callback 」を設定しておく。

publicなクライアントの場合、コードの横取りに対処する必要があるとのことなので、PKCE(Proof Key for Code Exchange by OAuth Public Clients)の設定を使うようにしてみる。

image.png

下の方にある「Advanced Settings」を展開すると、「Proof Key for Code Exchange Code Challenge Method」という項目が出てくる。「Plain」と「S256」が選択できるので、今回は「S256」を選択してみた。

「save」ボタンで保存する。

ユーザーの追加

確認用のユーザーも作っておく。やり方は下記を参照(「Keycloakの設定」という項目の最後のほう)。
https://qiita.com/yusuke-ka/items/1beef8d9e0bbeb052e5a#keycloak%E3%81%AE%E8%A8%AD%E5%AE%9A

次回予告

electronアプリでKeycloakと連携したログイン機能を実装するために、今回は、Keycloak側の設定を実施した。

次は、electronアプリを作成する。
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

node.js + express + nodemailer

node.jsでmail機能を実装するには…

node.jsでmail機能を実装するにあたってnpmのnodemailerというパッケージを使っていきます!

$ express app --view=ejs
$ cd app
$ npm install

ルートフォルダーにcontact.jsファイルを作って下記の記述をします

contact.js
"use strict";
const nodemailer = require("nodemailer");
async function main() {

  let transporter = nodemailer.createTransport({
    ignoreTLS:true,
    port: 1025,
    secure: false, // true for 465, false for other ports
  });

  let info = await transporter.sendMail({
    from: '"Fred Foo ?" <foo@example.com>', // sender address
    to: " s@gmail.com", // list of receivers
    subject: "成功!!!!", // Subject line
    text: "サンプルテキストです", // plain text body
  });

  console.log("送られたメッセージ: %s", info.messageId);

}

main().catch(console.error);


参考)https://nodemailer.com/about/

メールが送信されているかをチェックするためにmailcatcherを使います
mailcatcherを使うためのパッケージをインストールして起動してみます

$ npm install -g maildev
$ maildev

image.png

$ node contact.js

localhostの1080ポートが起動しているのでhttp://127.0.0.1:1080 にアクセスすると

image.png

image.png

ポート1025でメールを受け取ることができました!

あとがき

mail機能を実装するためにはamazon SESでもできるようです
今回nodemailerを使ってみましたが、圧倒的にメールサーバについての理解が乏しいと実感しました(´;ω;`)
mailについて学習し、また記事を書きます!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

puppeteerでドットインストールの総学習時間を取得する

オンライン学習サイトのドットインストールには学習時間を確認できる機能がある。
image.png
……がこの時間がどうも正しくない。実際の学習時間よりだいぶ少ない。おそらく回線が不安定な環境で動画を視聴しても視聴時間に加算されない。 → 自動取得した合計と視聴時間が一致していたので、ドットインストールの視聴時間は正しかった。

正しい学習時間が欲しいのでブラウザ自動操作ツールであるpuppeteerを利用して学習時間の合計を自動で求める。

注意

自動操作ツールでのアクセスは頻度が高すぎるとサービスへ負荷を与えてアクセスブロック等の対象となるので、適切にwaitを入れてあげると良い。

戦略

①puppeteerでヘッドレスブラウザ(目に見えないブラウザ)を起動する。
②ドットインストールのユーザ認証を行う。
③ドットインストールのプロフィールページへ遷移し、受講した講座のURL一覧を取得する。

④講座ページへ遷移し、受講完了した動画の視聴時間の合計を求める。(これを③で取得したURL分繰り返す)

⑤集めたデータを以下の形で出力する。

[{
    lessonName: 'C#入門',
    lessonUrl: 'https://dotinstall.com/lessons/basic_csharp',
    completeTime: '01:49:23',
    incompleteTime: '00:21:01'
}, {
    ......
}]

実装

①npmプロジェクトを作成し、puppeteerとログイン情報入力に使用するreadline-syncをインストールする。

$ npm init -y
$ npm i -S puppeteer readline-sync

②puppeteerの動作確認で、googleにアクセスしてみる。

main.js
const puppeteer = require('puppeteer');

// awaitを使うためasync関数を定義する。
async function main() {
    // puppeteerのブラウザを起動する。
    const browser = await puppeteer.launch();

    // ブラウザの新しいタブを開く。
    const page = await browser.newPage();

    // googleにアクセスする。
    await page.goto('https://google.com');

    // スクショを撮る。
    await page.screenshot({path: 'test.png'});

    // ブラウザを閉じる。
    await browser.close();
}

main();

上記を実行してpuppeteerからgoogleにアクセスできることを確認した。

②ログイン情報を入力させる。

ドットインストールにログインするためのログイン情報をユーザに入力させる。パスワード入力時は入力内容を出力させず履歴に残させないことがミソだ。

main.js
const puppeteer = require('puppeteer');
const readlineSync = require('readline-sync');

async function main() {
    // メールアドレスを入力させる。
    const mail = readlineSync.question('mail: ');

    // パスワードを入力させる。オプションで入力内容を出力させない。
    const password = readlineSync.question('password: ', {hideEchoBack: true});

    console.log(mail, password);
}

main();

③ドットインストールにログインする。

ドットインストールへのログインは以下の流れで行う。

ログインページへの遷移(https://dotinstall.com/login)

ユーザ名とパスワードのinput欄へ自動入力する。

ログインボタンを押下させる。

main.js
const puppeteer = require('puppeteer');
const readlineSync = require('readline-sync');

async function main() {

    ......

    // ログインページへの遷移
    await page.goto('https://dotinstall.com/login');

    // メールアドレスとパスワードの入力
    await page.evaluate(text => document.querySelector('#mail').value = text, mail);
    await page.evaluate(text => document.querySelector('#password').value = text, password);

    // ログインボタン押下
    await page.click('#login_button');

    ......

④受講した講座のURL一覧を求める。

ユーザ名をクリックしプロフィールページへ遷移する。

各講座へのリンクのaタグのhref属性を取得する。

講座のページへ遷移し時間を求める。

async function main() {

    ......

    await Promise.all([
        page.waitForNavigation({waitUntil: ['load', 'networkidle2']}),
        page.click('#login_button')
    ]);

    await Promise.all([
        page.waitForNavigation({waitUntil: ['load', 'networkidle2']}),
        page.click('a.user-name')
    ]);

    const urls = await page.evaluate(() => {
        const urls = [];
        const aElements = document.querySelectorAll('.cardBox > h3 > a');
        for (const aElement of aElements) {
            urls.push(aElement.getAttribute('href'));
        }
        return urls;
    });

    const result = [];

    for (const url of urls) {
        const lessonUrl = 'https://dotinstall.com' + url;
        await page.goto(lessonUrl, {waitUntil: ['load', 'networkidle2']});

        // 負荷軽減のため3秒待機する
        await page.waitForTimeout(3000);

        const lessonName = await page.$eval('.package-info-title span', element => element.innerHTML);

        const [completeTime, incompleteTime] = await page.evaluate(() => {
            let completeTime = 0;
            let incompleteTime = 0;
            const sectionElements = document.querySelectorAll('#lessons_list > li');
            for (const sectionElement of sectionElements) {
                const time = sectionElement.querySelector('.lessons-list-title > span').innerHTML;
                const [, min, sec] = time.match(/\((\d\d)\:(\d\d)\)/);
                const seconds = parseInt(min) * 60 + parseInt(sec);
                const isCompleted = sectionElement.querySelector('.lesson_complete_button > span').innerHTML === '完了済';
                if (isCompleted) {
                    completeTime += seconds;
                } else {
                    incompleteTime += seconds;
                }
            }
            return [completeTime, incompleteTime];
        });

        function sec2time(sec) {
            return `${parseInt(sec / 3600)}:${parseInt((sec / 60) % 60)}:${sec % 60}`;
        }

        result.push({
            lessonName,
            lessonUrl,
            completeTime: sec2time(completeTime),
            incompleteTime: sec2time(incompleteTime)
        });
    }

    console.log(result);

    ......

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む