20190822のJavaScriptに関する記事は29件です。

micro:bitでLEDと温度センサーを試してみた~その2~

数値を2桁表示にしてみた。
やはり、LEDの数的に、厳しいね^^;
28って表示してるけど、かろうじて見える感じ。
led.jpg

もっとLEDがいっぱいのモジュールをゲットしないとなぁ。

とりあえず、変更したコードを・・・
コードを載せた後きづいたけど、配列一つでもできたな、これ。
わかりづらくなるけど。

標準のLEDで数字を2桁表現する方法も考えよう。

basic.forever(function () {
    let ledL = [
        [
              [0, 0]
            , [0, 0]
            , [0, 0]
            , [0, 0]
            , [0, 0]
        ], [
              [0, 1]
            , [1, 1]
            , [0, 1]
            , [0, 1]
            , [0, 1]
        ], [
              [1, 1]
            , [0, 1]
            , [1, 1]
            , [1, 0]
            , [1, 1]
        ], [
              [1, 1]
            , [0, 1]
            , [1, 1]
            , [0, 1]
            , [1, 1]
        ], [
              [1, 0]
            , [1, 1]
            , [1, 1]
            , [0, 1]
            , [0, 1]
        ], [
              [1, 1]
            , [1, 0]
            , [1, 1]
            , [0, 1]
            , [1, 1]
        ]
    ]
    let ledR = [
        [
              [0, 1, 0]
            , [1, 0, 1]
            , [1, 0, 1]
            , [1, 0, 1]
            , [0, 1, 0]
        ], [
              [0, 1, 0]
            , [1, 1, 0]
            , [0, 1, 0]
            , [0, 1, 0]
            , [0, 1, 0]
        ], [
              [1, 1, 1]
            , [0, 0, 1]
            , [0, 1, 0]
            , [1, 0, 0]
            , [1, 1, 1]
        ], [
              [1, 1, 1]
            , [0, 0, 1]
            , [0, 1, 1]
            , [0, 0, 1]
            , [1, 1, 1]
        ], [
              [1, 0, 1]
            , [1, 0, 1]
            , [1, 1, 1]
            , [0, 0, 1]
            , [0, 0, 1]
        ], [
              [1, 1, 1]
            , [1, 0, 0]
            , [1, 1, 1]
            , [0, 0, 1]
            , [1, 1, 1]
        ], [
              [1, 1, 1]
            , [1, 0, 0]
            , [1, 1, 1]
            , [1, 0, 1]
            , [1, 1, 1]
        ], [
              [1, 1, 1]
            , [0, 0, 1]
            , [0, 1, 0]
            , [0, 1, 0]
            , [0, 1, 0]
        ], [
              [1, 1, 1]
            , [1, 0, 1]
            , [0, 1, 0]
            , [1, 0, 1]
            , [1, 1, 1]
        ], [
              [1, 1, 1]
            , [1, 0, 1]
            , [1, 1, 1]
            , [0, 0, 1]
            , [1, 1, 1]
        ]
    ]

    while (true) {
        let tmp = input.temperature()
        let strTmp = tmp.toString()
        strTmp = ("0" + strTmp).substr(("0" + strTmp).length - 2, 2)
        for (let i = 0; i < 2; i++) {
            let num = parseInt(strTmp.charAt(i))
            let max = 2 + i
            let xOffset = 2 * i
            console.log("num" + xOffset)
            for (let y = 0; y <= 5 - 1; y++) {
                for (let x = 0; x <= max; x++) {
                    let flag = 0;
                    if (i == 0) {
                        flag = ledL[num][y][x]
                    } else {
                        flag = ledR[num][y][x]
                    }
                    if (flag == 0) {
                        led.plotBrightness(x + xOffset, y, 0)
                    } else {
                        led.plotBrightness(x + xOffset, y, 255)
                    }
                }
            }
        }
        basic.pause(1000)
    }
})

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

JavaScriptってこんな感じなんだ!JavaScriptに触れてみた

未来電子テクノロジーでインターンをしている mrm1027 です。

【プログラミング初心者であるため、内容に誤りがあるかもしれません。
 もし誤りがある場合は、すぐさま修正しますのでどんどん指摘してください。】

はじめに

プログラミングを始めて、実は2ヶ月が経ちました。
自由課題が予想以上に遅れをとっていて早く仕上げて、次のステップに進みたいと思っている今日この頃です…。

JavaScript

今自由課題をやっていて「画像切り替え機能」を実装するために、JavaScriptを少しずつかじり始めました。
ProgateのJavaScriptパートの最初の印象は。「うわ、数学やん…」でした。
私は数学がどちらかと言うと好きではあったのに、高校2年生のときに諦めてしまったことをきっかけにずっと苦手意識を持っています。

ここまでの範囲では、一応文系の私でもなんとかプログラミングはできてきましたが、ここから少しずつ苦労していきそうなフラグが立っています。。

しかし、まだ基本的な部分は理解ができるのでなんとか耐えてますね。。

そこで今回は、JavaScriptの基本の基本を復習がてら簡単にまとめたいと思います。

変数・定数について 

▼変数 (let)

コードを書くときに、長い文字列や、同じ数値・計算式が何度も出てくるときがあります。
その都度同じコードをわざわざ書くのは相当面倒ですよね。
そんなときに活躍するのが「変数」の存在です。

変数とは、さまざまな文字列や数値を入れておく箱のイメージを持つとよいと説明されることが多いです。

▼定数 (const)

定数は変数と違って、値を更新して使いまわすことができません。
デメリットのように感じますが、実際のコードを書く上では役立ちよく使います。
例えば、長いコードを書くなかで同じ値を使いまわす場合、途中で値が書き換わってしまうことがあります。
長いコードとなるとどこにバグがあるか分からなくなり、更新できる変数を使うとバグを発見するのに時間がかかり面倒です。
そこで、役立つのが「定数」というわけです。

まとめ

変装・定数などが理解できるようになりました。
ひとつひとつ出力するのが楽しく感じます。
これからの知識の蓄積を忘れず、学習を進めていきます。

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

Adobe ExtensionのFirst Step

はじめに

AdobeアプリケーションのExtension(通称 CEP:Common Extensibility Platform)の画面開発を始めるために必要な情報をまとめる。

開発ツール

Visual Studio Code 1.37.1

あると便利な Visual Studio Code 拡張

  1. ExtendScript
  2. CC Extension Builder
  3. Adobe Script Runner

Getting Started

上記の拡張がインストールしている前提で話を進める

  1. コマンドパレットを実行
  2. 「Create a New CC Extension」コマンドを見つける
  3. Extension IDを入力(デフォルトは「com.example.helloworld」)
  4. Extensionの名称を入力
  5. どのテンプレートを利用するか選択

    a. basic

    単純な「Hello World」のよくあるテンプレート
    中身のファイルには本当に基本的なものしか記述されていない
    

    b topcoat

    AdobeのオープンソースCSSライブラリの名称
    CSSを導入したもの
    

    c spectrum

    topcoatの代わりにspectrumというCSSライブラリを用いたもの
    

    d theme
    カスタム

※テンプレートの作成場所はmacOSXの場合は下記である。

~/Library/Application Support/Adobe/CEP/extensions/

ここに置いてあるExtensionは、manifestで指定したアプリケーションとバージョンすべてで利用可能。

例(InDesign)

InDesignで使えるように、manifestファイルを修正する。 CSXSフォルダにあるmanifest.xmlを開き以下のように書き換える。

<Host Name="IDSN" Version="10.0" />
....

単一バージョン表記だと、そのバージョン以降実行可能となる(この例では10.0=CC2014以降)。動作バージョンを範囲で指定する場合は[10.0, 13.1]のように記述する。

※manifest.xmlに関してはこちらを参考にすること。

DebugModeの設定

Debugする時は、以下のコマンドを実行する。

defaults write com.adobe.CSXS.7 PlayerDebugMode 1 //ほぼCC 2017用
defaults write com.adobe.CSXS.8 PlayerDebugMode 1 //ほぼCC 2018用
defaults write com.adobe.CSXS.9 PlayerDebugMode 1 //ほぼCC 2019用

実行

manifestでバージョン指定したInDesignを起動し、ウィンドウメニュー>エクステンションと開くと、先程サンプルとして作成したCEPが選べることができ、実行することができる。

証明書の発行

ここより、 ZXPSignCmd をダウンロードする。以下のコマンドを実行する。

ZXPSignCmd -selfSignedCert 国名コード 地域 組織 名前 パスワード 出力ファイルパス.p12 for osx
ZXPSignCmd.exe -selfSignedCert 国名コード 地域 組織 名前 パスワード 出力ファイルパス.p12 for win

ツールのコマンドはPACKAGING AND SIGNING ADOBE EXTENSIONS TECHNICAL NOTEを参考にすること。

パッケージ化

以下のコマンドを実行する。

ZXPSignCmd -sign ソースフォルダのパス 出力ファイルパス.zxp 証明書.p12 設定した証明書のパスワード for osx
ZXPSignCmd.exe -sign ソースフォルダのパス 出力ファイルパス.zxp 証明書.p12 設定した証明書のパスワード for win

.zxp が生成される。

ZXPをインストールする方法

ZXPをAdobe製品へインストールするには、ツールが必要となる。ExMan Command Line Toolなるものを使用する。

  1. Adobe Extension Manager CCをこちらからダウンロードする。
  2. Adobe Extension Manager CCをインストールする。
  3. ExManCmdが配置されていのフォルダへ移動する。
    • Windowsの場合:C:\Program Files\Adobe\Adobe Extension Manager CC\
    • MacOSXの場合:/Applications/Adobe Extension Manager CC/Adobe Extension Manager CC.app/Contents/MacOS/
  4. コマンドラインツールを実行しインストールを開始する。

以上。

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

Adobe ExtensionのFirst step

はじめに

AdobeアプリケーションのExtension(通称 CEP:Common Extensibility Platform)の画面開発を始めるために必要な情報をまとめる。

開発ツール

Visual Studio Code 1.37.1

あると便利な Visual Studio Code 拡張

  1. ExtendScript
  2. CC Extension Builder
  3. Adobe Script Runner

Getting Started

上記の拡張がインストールしている前提で話を進める

  1. コマンドパレットを実行
  2. 「Create a New CC Extension」コマンドを見つける
  3. Extension IDを入力(デフォルトは「com.example.helloworld」)
  4. Extensionの名称を入力
  5. どのテンプレートを利用するか選択

    a. basic

    単純な「Hello World」のよくあるテンプレート
    中身のファイルには本当に基本的なものしか記述されていない
    

    b topcoat

    AdobeのオープンソースCSSライブラリの名称
    CSSを導入したもの
    

    c spectrum

    topcoatの代わりにspectrumというCSSライブラリを用いたもの
    

    d theme
    カスタム

※テンプレートの作成場所はmacOSXの場合は下記である。

~/Library/Application Support/Adobe/CEP/extensions/

ここに置いてあるExtensionは、manifestで指定したアプリケーションとバージョンすべてで利用可能。

例(InDesign)

InDesignで使えるように、manifestファイルを修正する。 CSXSフォルダにあるmanifest.xmlを開き以下のように書き換える。

<Host Name="IDSN" Version="10.0" />
....

単一バージョン表記だと、そのバージョン以降実行可能となる(この例では10.0=CC2014以降)。動作バージョンを範囲で指定する場合は[10.0, 13.1]のように記述する。

※manifest.xmlに関してはこちらを参考にすること。

DebugModeの設定

Debugする時は、以下のコマンドを実行する。

defaults write com.adobe.CSXS.7 PlayerDebugMode 1 //ほぼCC 2017用
defaults write com.adobe.CSXS.8 PlayerDebugMode 1 //ほぼCC 2018用
defaults write com.adobe.CSXS.9 PlayerDebugMode 1 //ほぼCC 2019用

実行

manifestでバージョン指定したInDesignを起動し、ウィンドウメニュー>エクステンションと開くと、先程サンプルとして作成したCEPが選べることができ、実行することができる。

証明書の発行

ここより、 ZXPSignCmd をダウンロードする。以下のコマンドを実行する。

ZXPSignCmd -selfSignedCert 国名コード 地域 組織 名前 パスワード 出力ファイルパス.p12 for osx
ZXPSignCmd.exe -selfSignedCert 国名コード 地域 組織 名前 パスワード 出力ファイルパス.p12 for win

ツールのコマンドはPACKAGING AND SIGNING ADOBE EXTENSIONS TECHNICAL NOTEを参考にすること。

パッケージ化

以下のコマンドを実行する。

ZXPSignCmd -sign ソースフォルダのパス 出力ファイルパス.zxp 証明書.p12 設定した証明書のパスワード for osx
ZXPSignCmd.exe -sign ソースフォルダのパス 出力ファイルパス.zxp 証明書.p12 設定した証明書のパスワード for win

.zxp が生成される。

ZXPをインストールする方法

ZXPをAdobe製品へインストールするには、ツールが必要となる。ExMan Command Line Toolなるものを使用する。

  1. Adobe Extension Manager CCをこちらからダウンロードする。
  2. Adobe Extension Manager CCをインストールする。
  3. ExManCmdが配置されていのフォルダへ移動する。
    • Windowsの場合:C:\Program Files\Adobe\Adobe Extension Manager CC\
    • MacOSXの場合:/Applications/Adobe Extension Manager CC/Adobe Extension Manager CC.app/Contents/MacOS/
  4. コマンドラインツールを実行しインストールを開始する。

以上。

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

【JavaScript】forEach, for of 【繰り返し - 配列】

forループ以外で、配列の中身をそれぞれ取り出したい時

const bmth = ["bring", "me", "the", "horizon"]

期待する出力結果

"0 - bring"
"1 - me"
"2 - the"
"3 - horizon"

forEach()

bmth.forEach((item, index)=>{
  console.log(`${index} - ${item}`)
})

for of

for( let [index, item] of bmth.entries()){
  console.log(`${index} - ${item}`)
}

* indexいらない、各要素だけ欲しい時

for (let item of bmth) {
  console.log(item)
}

参考にしたい記事

https://qiita.com/diescake/items/70d9b0cbd4e3d5cc6fce

https://qiita.com/shibukawa/items/4cae2a1410754d519232

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

JavaScript | forEach, for of (繰り返し - 配列)

forループ以外で配列の中身をそれぞれ取り出したい時

const bmth = ["bring", "me", "the", "horizon"];

期待する出力結果

"0 - bring"
"1 - me"
"2 - the"
"3 - horizon"

forEach()

bmth.forEach((item, index)=>{
  console.log(`${index} - ${item}`)
})

for of

for( let [index, item] of bmth.entries()){
  console.log(`${index} - ${item}`)
}

* indexいらない、各要素だけ欲しい時

for (let item of bmth) {
  console.log(item)
}

// 期待する出力結果
// "bring"
// "me"
// "the"
// "horizon"

参考にしたい記事

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

Javascriptの厳密な関係比較について

はじめに

先日Javascriptでコードを書いていたところ、if文で値の比較を行うコードが必要になりました。それ自体はごく普通のことだと思うのですが、普段PythonとかGolangとか書いてる自分が面食らったのが、勝手に型変換を行なって値の比較が行われることです。

Python 3.6.8 (default, Jan 14 2019, 21:12:09) 
[GCC 4.2.1 Compatible Apple LLVM 10.0.0 (clang-1000.10.44.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> x="1"
>>> y=2
>>> x<y
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'str' and 'int'
>>> 
package main

func main() {
    x := "1"
    y := 2
    x < y
}

// 以下The Go Playground出力
// ./prog.go:6:4: invalid operation: x < y (mismatched types string and int)
//
// Go build failed.
node.js
Welcome to Node.js v12.4.0.
Type ".help" for more information.
> x="1"
'1'
> y=2
2
> x<y
true
> 

上記のサンプルの通り、pythonでは型が異なる変数同士を比較すると例外を吐いて停止します。変数型があるGolangではビルドすらできません。一方、Javascriptの場合特にエラー等を出すことなく比較を行い、その結果を返しています。

Javascriptは値が等価であるかどうかを、型が同一であるかどうかを含め比較する演算子(===)があります。

node.js
Welcome to Node.js v12.4.0.
Type ".help" for more information.
> x="1"
'1'
> y=1
1
> x===y
false
> x==y
true
> 

異なる型(上記の例ではstr型の"1"とint型の1)の比較で、厳密な比較(===)はfalseを、厳密ではない比較(==)はtrueを返していますね。
このように、Javascriptには厳密な等価演算子が存在する一方、比較演算子には厳密な演算子がありません。この件について色々と調べてみました。

厳密な比較の実現

まずは仕様書を確認する

厳密な比較の実現を目指すために、まずは本当にそのような演算子が存在しないのか確認してみます。執筆現在(2019年8月22日)時点での最新のECMAScriptであるECMAScript2018を参照してみます。
すると、Abstract Equality Comparison(型変換の比較)とStrict Equality Comparison(厳密な比較)、およびAbstract Relational Comparison(抽象的な関係比較)の章はありますが、Strict Relational Comparison(以下、厳密な関係比較)ないしはそれに類する内容の章は見当たりません1

7.2.13 Abstract Relational Comparison
7.2.14 Abstract Equality Comparison
7.2.15 Strict Equality Comparison

また、Abstract Relational Comparisonの中を読んでみると、色々と書いてありますが、要するに一度プリミティブ型(stringやintegerなど、Javascriptに最初から存在する型)へ変換した上で比較を行うと記載してあります。
つまり、型が異なる値同士を比較した場合、問答無用でfalseを返すような類のものではありません(Strict Equality Comparisonの場合、比較される値同士の型が異なる場合、falseを返すと明記されています)。したがって、厳密な関係比較を行いたい場合、プログラマが何らかの手段で実装する必要があります。

何らかの手段で実装する

厳密な関係比較を行いたい場合、手っ取り早いのはif文の条件式にtypeofを混ぜ込んでしまうことです。Javascriptで変数の型が等しいか確認したい場合、以下のようなセンテンスが使用されます。

typeof(x) === typeof(y)

したがって、値の大小関係を比較しつつ、変数の型が等しいかを確認したい場合は、関係比較と上記のセンテンスの論理積を取ることになります。具体的には以下のような例になります。

node.js
Welcome to Node.js v12.4.0.
Type ".help" for more information.
> x="1"
'1'
> y=2
2
> x < y && typeof(x) === typeof(y)
false
>

ちゃんとfalseになりましたね。

そもそもなぜないんだ

Javascriptのように暗黙に型変換を行なう場合、プログラマが意図した動作とは異なるにも関わらずif文を通過してしまい、結果としてその先の処理でエラーが発生した時、問題の箇所の特定を難しくしてしまいます。ECMAはどのような理由づけの元、このような仕様を策定したのでしょうか。そのあたりの議論を確認するべく、ECMAのメーリングリストをES Discussionで確認してみました。

おそらくいくつかの議論が行われた上で、実装を却下する結論が下されたのだろうと予想していたのですが、意外なことにそもそもあまりこの件についての議論が見つかりませんでした。ひとしきり調べてみましたが、バグレポートやリンク切れの議論などを除くと、以下のディスカッションが参考になるでしょうか。

拙い英語力でひとしきり読んでみたところ、過去に議論されたことがないので検討してみよう、みたいな流れになって、後半では実装されるならどのような形態が良いかの議論になっていました。
厳密な関係演算子が却下されたのではないとすると、次はなぜ厳密な比較演算子が実装されたのかや、比較演算子自体がプリミティブ型に変換した上で比較するという実装がされたのかが気になりましたが、残念ながらそれに関する議論を見つけることができませんでした...。

終わりに

Javascriptのif文で値の関係演算を行う場合で、特に異なる型同士の比較をfalseにしたい場合はtypeofを噛ませる必要があります。
Javascriptの関係演算子の実装についての議論は見つけることができませんでした。竜頭蛇尾な結果に終わってしまい残念ですが、今後Javascriptの型変換について知見が溜まった時に、何らかの結論が得られるのではと考えています。

参考

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

JSに列挙体を実装

概要

先日自作したsr-enumというnpmパッケージの紹介になります。sr-enumはJavaScriptで列挙体を扱うためのパッケージで、2019年7月からnpmに公開しています。

何が便利なのか

状態の管理が楽になると思います。

トークンの有効性を検証する

未検証Unverified・検証中Verifying・有効Valid・無効Invalidの3状態をとるTokenクラスを作成して、トークンの状態に応じて処理を分ける場合を考えます。

const Token = new Enum("Unverified", "Verifying", "Valid", "Invalid");

変数tokenに未検証Unverifiedなトークンを代入します。

let token = new Token.Unverified("トークン");

tokenに未検証Unverifiedなトークンが代入されている場合、トークンの有効性を検証します。

// トークンが未検証な場合、onTokenIsUnverifiedを呼び出す
token = match(token) ({
    Unverified: onTokenIsUnverified,
    default: token
});

function onTokenIsUnverified( unverifiedToken ) {
    // トークンが未検証な場合の処理

    // 何らかの非同期な検証処理
    verifyToken( unverifiedToken  )
        .then( validToken => { token = new Token.Valid( validToken ) } )
        .catch( invalidToken => { token = new Token.Invalid ( invalidToken  ) };

    // 検証中トークンを返す
    return new Token.Verifying( unverifiedToken );
}

コンテンツを非同期で読み込む

あるコンテンツを非同期で読み込むときに、読み込みが完了しているのかどうかを判定するために、読み込み中Loading・読み込み済みLoadedの2状態を取る列挙体を考えます。

const Content = new Enum( "Loading", "Loaded")

コンテンツの状態に応じて内容を変更するような、ReactComponentを作ります。

const Content = props =>
    match(props.content)({
        Loading: _ => /*ロード中の表示内容*/,
        Loaded: content => /*ロード後の表示内容*/
    });

インストール

npm install sr-enum

機能

  • 列挙体(タグ付きバリアント)の作成
  • パターンマッチ

require / import

必要に応じて、いらないものは消して下さい。

const {Enum, match, maybe, option, result, Maybe, Option, Result} = require("sr-enum");
import {Enum, match, maybe, option, result, Maybe, Option, Result} from "sr-enum";

列挙体の作成

const 列挙体名 = new Enum( 列挙子名, 列挙子名, ...);

const Request = new Enum( "Get", "Post", "Delete" );

列挙子の作成

new 列挙体名.列挙子名( 任意の値 );

const request = new Request.get( "/api/hoge" );

パターンマッチ

単純な分岐

match(列挙子が代入された変数)({
    列挙子名: 値を受け取る変数 => 処理,
    列挙子名: 値を受け取る変数 => 処理,
    default: デフォルト値
)};

match( request )({
    Get: url => /*処理*/,
    Post: url => /*処理*/,
    default: /*その他の場合*/
});

複雑な分岐

match(列挙子が代入された変数)(
    [パターン, 値を受け取る変数 => 処理],
    [パターン, 値を受け取る変数 => 処理],
    [match.default, デフォルト値]
);

match( request )(
    [Request.Get(Url.Valid()), validUrl => /*処理*/],
    [Request.Get(Url.Invalid()), _ => /*エラー処理*/],
    [Request.post(), url => /*処理*/],
    [match.default, デフォルト値]
);

if letライクな使用

const 変数 = パターン(列挙子が代入された変数);
if(変数 !== unMatched) {
    パターンに合致したときの処理
}

const url = Request.Get(Url.Valid())(request);
if( url !== unMatched ) {
    /*処理*/
}

詳細

npm

github

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

asp.net coreで書く細かいjavascript module の記法

asp.net coreと言えど javascript コードを書くのに1個のファイルにごちゃごちゃと色んなものを混ぜたくはないので、適当なモジュールに分割したい。

モジュールの記述

モジュール "Mod" を_mod.js に書く。

/**
* 何か機能を提供するモジュール
*/
var Mod = Mod || (function(){
   var privateVariable = 1;

    var o = {
        /**
        * 何かを実行する関数
        */
        method: function(){
            return privateVariable;
        }
    }
    return o;
})();

bundle

拡張機能 Bundler & Minifier をインストールしておき、 bundleconfig.jsonを書く

[
  {
    "outputFileName": "wwwroot/js/global.js",
    "inputFiles": [
      "wwwroot/js/src/_mod.js",
    ],
    "minify": {
      "enabled": true,
      "renameLocals": true
    },
    "sourceMap": false
  }
]

これで bundle を実行すると wwwroot/js/global.js が生成されるので、 _Layoutあたりで参照しておけば良い

jsdoc

匿名関数に必要なものを入れつつ jsdocが有効になるやり方を探っていたけど、モジュールの記述で書いたような var Foo = Foo || (function(){})(); みたいな書き方に落ち着きつつある。

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

【JavaScript】元インフラエンジニアの初プログラミング【スロットゲーム】

タイトル通り筆者は元インフラエンジニアで現在は出向の関係で非エンジニアです。
近いうちにまたエンジニアとして働きたいため今はフロント系(HTML,CSS,JavaScript)の勉強してます。

最初はProgate等の学習サイトで勉強してたんですがその勉強どうしようかなと頭を悩ませたんですが実際にコード書いて物作るのが一番と思い
今回初めてJavaScriptでコードを書いたのでその記録と
自分と同じように初学者でどう勉強すればと迷ってる人に向けにどうやって勉強のお題を見つけたかとかどう勉強していったかなどの一例になればと思い投稿します。

ちなみに実際に作ったものはスロットゲームでこの記事の方プログラムをほぼほぼ真似してます。
https://qiita.com/hacchi56/items/0f2ea20de8b9d7046d45

では実際に私がどう勉強していったか書いていきます。

学習サイトでやったことはほぼ忘れてた件

まず一番最初に個人の感想というか私と同じように学習サイトに結構時間割いてる初学者の方へ個人的に気を付けた方がいいと思った点を書きます、
実際にコードを書いていこうと思うと具体的な書き方が浮かばないうえにコードを書こうとしても細かい構文の書き方が出てこないことがほとんどでした。
私はProgateを使って学習を進めていましたがこれだけではコードは書けるようにはならないと思います。
こういった学習サイトは概要理解とかどんな構文なのかとか「ふーん」っていう程度で知れるくらいのものとして利用した方がいいかもしれません。
学習サイトで概要をなめたら実際にお題を探してコード書く!っていう流れの方が身になるんじゃないかと今回コード書いてみての感想です。

お題探し

では実際にコードを書いてみようと思ったのですが
学習した言語今回はJavaScriptでどんなものが作れるかもよくわからなかったので自分で一からコードを書くという選択肢は私にはありませんでした。
そこで先人の作ったものを真似てみようと思い立ってGoogle先生で「JavaScript 初心者」とか「JavaScript 初心者 お題」で探しました。
実際に探すと今回のビンゴゲーム以外にもいろいろなものがありましたが
自分と同じく初学者がお題を選定する上で大事かなーと思うのはなるべく簡単そうなもの、作りがそんなに凝ってないものを選びましょう。
作りが豪華なもの = 複雑なもの になりがちだと思うのでなるべく最初はハードルの低いものを選びましょう。

選定したコードの理解・熟読

次に選んだお題の動きとコードを理解できるまで読みました。
わからないところはGoogle先生に聞きつつ書いてあるコードがどういった動きをするのかを読み込みます。
私は3周くらいコードを読みましたが、下記のような流れでよみこんでいきました。
- 1周目はわからないとこは飛ばしつつ全体的に流して読む
- 2周目はわからないとこは1つずつ調べていってわからない構文の理解をする
(理解できたら対象のコード近くにコメントで意味を書いていく)
- 3周目はそれぞれの構文を理解した上で改めて全体のコードがどの動きに対応しているのかを確認する

※どうしても読んでて理解できない時は知見のある人に聞いてしまいましょう。
周りにエンジニアがいないよって人は teratail【テラテイル】|ITエンジニア特化型Q&Aサイトなどの質問サイトなどに頼る方法なんかもあるそうです。(私はまだ利用したことないですごめんなさい)

コード書く際に心掛けたこと

勉強のために書くのでなるべく元のコードは見ないようにわからない部分はGoogle先生に聞いて書くようにしてました。
まあ最初の白紙の状態から"いざ書くぞ"ってなったときはチラチラ見ながら書いてました。
ですが最初の書き出しが終わると事前にコードを熟読しているのもあって構造はなんとなく覚えてるというかわかってるので途中まではなんとなく書けました。(それでもちょいちょいチラ見はしてますw)
逆に絶対にやらないようにしていたのが『何で動いてるかわからないけどググってきたものそのままコピペ』だけはやらないようにしました。
これをやってしまうと勉強の意味がなくなりますしこの癖がつくと実務で自分の書いたコードなのに仕様がわかりませんとか言うことになりそうだったので

実際に書いたコード

書いたコードも貼っておきます。
CSSも書いて見栄えをもう少しきれいしようかとおもいましたが今回はJavaScriptの勉強が主な目的だったのでBootstrapでセンタリングとボタンの色付け程度にしておきました。

index.html

<!DOCTYPE html>
<html>
<head>
    <meta lang="ja">
    <meta charset="UTF-8">
    <title>Slot Game</title>
    <script type="text/javascript" src="slot.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</head>
<body>
    <h1 class="text-center">スロットゲーム</h1>
    <div class="container slot-box text-center">
        <p>得点<span id="score">0</span></p>
        <div class="row">
            <div class="slot1 col-6">
                <p id="slot0">0</p>
                <input class="btn btn-primary" type="button" value="STOP" id="stop0">
            </div>
            <div class="slot1 col-6">
                <p id="slot1">0</p>
                <input class="btn btn-primary" type="button" value="STOP" id="stop1">
            </div>
        </div>
    </div>
</body>
</html>

JavaScriptに関しては参考にしたコードとスロットを回す処理のところをすこし変えました。
元のコードはもう少し短かったんですが自分的に読みにくいと思ったので長くても自分が読みやすいと思ったコードに変えてます。
(もとコードはsetTimeoutを使用してましたが私はsetIntervalを使用してます)

slot.js

//HTMLが読み込まれた後にJavaScriptを実行します。
window.onload = ()=>{
    //即時実行とスコープの限定
    (()=>{
        //変数定義
        var interval = 400;
        var stopCount = 0;
        var results = [];
        var timeOutId = [];
        var score = 0;
        var target = 0;


        //一定時間間隔を開けスロットを回す処理を呼び出す
        function startSlot(num){
            timeOutId[num] = setInterval(startSlotProcess, interval, num);
        }

        //スロットを回す処理内容
        function startSlotProcess(num){
            //スロットの現在の数値を取得する
            var slotValue = document.getElementById(`slot${num}`)

           if(slotValue.textContent < 9){
                slotValue.textContent++;
           }else{
            slotValue.textContent = 0;
           }
        }

        //スロットを左右スタートさせる
        startSlot(0);
        startSlot(1);


        //スロットを止める処理内容
        function stopSlot(num){
            //スロットのカウントを止める
            clearInterval(timeOutId[num]);
            //スロットが止まった際の数字を取得する
            results[num] = document.getElementById(`slot${num}`).textContent;
            //集計用に何かいスロットを止めたか集計する
            stopCount++;
            //集計用の関数を動かす
            aggregate();
        }

        //ボタンを押された際に左右それどれのスロットを止める
        document.getElementById("stop0").onclick = function(){
            stopSlot(0);
        }

        document.getElementById("stop1").onclick = function(){
            stopSlot(1);
        }

        //集計処理
        function aggregate(){
            if(stopCount === 2){
                //スロットの数値を合わせられた時
                if(results[0] === results[1]){
                    alert("おめでとう!もう一回遊べるドン!!!");

                    //集計処理を行う
                    score += 200;
                    getScore();

                    //スロットが回る感覚を短くする
                    interval *= 0.8;

                    //スロットを止めたカウント数をリセットする
                    stopCount = 0 ;

                    //スロットのスタート
                    startSlot(0);
                    startSlot(1);
                }else{
                    alert("残念!そこまで!!")
                }
            }
        }

        //集計結果を表示させるための処理
        function getScore(){
            target = document.getElementById("score");
            target.textContent = score;
        }

    })();
}

最後に

初めて自分でコードを書いてみましたが実際に完成すると嬉しいですね。
最初は簡単なものしか作れないかもしれませんがしばらくは人のコードを真似させてもらって(自分で少しでもオリジナル要素は入れつつ)勉強していこうかと思います。
私と同じような初学者の方で勉強の仕方を迷っている方の参考程度になればうれしいです^^

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

チャットガチャにテーマガチャを実装したときの話

技術でこの世から寂しさを消すことは可能か?

匿名でランダムな相手と1対1のチャットが気軽に楽しめるwebサービスを開発し運営している。
だが、いくら寂しいからといって誰とも分からない相手と話すことが可能だろうか?
どちらかが話題を振り、相手が答えるというのは何気に難しい。
的外れなことを聞いてしまえば、それだけで切断されることもあるだろう。
その問題を解決するべく、テーマガチャというお題をシステム側が提供する機能を実装した。

どうやって実装するか

スマホからのアクセスが多いと予想している。

スマホからみた画面

狭い。
SS.png

この画面に「お題を出すボタン」を追加するのは正直しんどい。
デザインが崩れるし、テーマガチャなんぞ使わずに会話したい人達にとって邪魔でしょうがない。

ゆえにこういう感じにした。

 実装済み画面

SS 2.png

フォームに「@話題」とだけ入れて送信すると話題が表示される。
無駄なアニメーションはない。
話題が盛り上がらなさそうなら、何度でも送信するとお題をくれる。
お題は全部で100近くある(数えてない)。少しドキッとするお題も入っている。
これによって「永久に」暇をつぶすことが可能になった。
あえて、同じお題を連続で出すようにしている。(重複されることも稀にある)
システム側がボケることで匿名の二人に共通の敵のようなものが誕生し仲良くなれるかもしれない。

ダメなUI

最悪なのはフォームの横幅を小さくして「お題ボタン」を追加すること。
さらに、ボタンを押したらルーレットのようなものが表示され無駄なアニメーションがでてしまうこと。
そういうことは、二人の会話にとって邪魔でしょうがない。
チャットをしにきているのであって、ルーレットを楽しみにしにきているわけではない。
また、テーマガチャを追加したことを知らない実装する前に遊びに来ていたユーザーもそのままの形で使えるようにしておいたほうがいい。
コロコロとUIが変更されるのは、ストレスになるだろうとも思う。

既に稼働しているシステムにコードを追加する話

こういったファイルを1つ新規で作成する。(javascriptだがnode.jsなのでサーバー側)

theme.js
const wadai_array = new Array(
  "最近見た映画",
  "YouTube",
  "ハマったゲーム",
  "アニメ",
  "好きな漫画",
  "旅行の思い出",
  "友達",
  "最近読んだ本",
  "好きな食べ物"

 //これがずらずらと100件ぐらい続く。
 //もしもシリーズや きのこVSたけのこのようなVSテーマもある
}

exports.func = function(){
  return wadai_array[Math.floor(Math.random() * wadai_array.length)];
};

上のファイルを読み込む

app.js
  //トークテーマを送信
  socket.on('theme_make', function(data) {
    var f = require('./theme.js'); //実際は圧縮されているので.min.jsになる
    io.sockets.in(data.roomid).emit("theme_get", f.func());
  });

このファイルを「@話題」と送信されたときに呼び出すようにすると簡単に実装できた。

何事も工夫次第

今回は話題を提供するテーマガチャを追加した。
@クイズと送信すればクイズがランダムに出てくるようにも作れるだろう。
良かった点は、フォームと送信を使って新機能を追加できたこと。
無駄なUIを作らなくて良かったことと、ルーレットのような無駄な演出を入れなくてよかったこと。
無駄なUIを作ってしまうと次の新機能のときに困ることになる。
見えないところで新しい何かが実装できるとシンプルで良いと思った。
また、実装するプログラムも当然だが別ファイルをつくって読み込ませるといい。
新しいお題を追加するときは、お題が書かれているファイル(theme.js)だけを開けばいいからだ。
プログラムもデザインも全て工夫なんだなと改めて思った。

おわり

テーマガチャがあれば無限に話すことができると思うので一度遊んで見て欲しい。
効果音をONにすれば、LINEのようにポン!ポン!と音が出る。
かなり気持ちいいサービスになっていると思う。

さて、技術でこの世から寂しさを消すことは可能なのだろうか?

チャットガチャ
https://chatgacha.com/

Twitter
https://twitter.com/ryuuga_h

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

【駆逐してやる...】JavaScriptでExcelブックを配列化【1ファイル残らず!】

久々の業務で役立つ(かも?)シリーズです。

「受け取ったデータが.xlsxだった...」
「社内システムが.xlsxしか吐き出してくれない...」
.xlsxをこねくり回すと全身に拒絶反応が...

そのような絶望とも今日でおさらば!

js-xlsx

.xlsx以外にも.xls.odsなど、大抵の表計算ファイルをJavaScriptから扱えます。

もちろんShift-JISにも対応しており、なんとNode/Browserどちらでも使えます。

まさに救世主!

導入

Browser
<script src="https://cdn.jsdelivr.net/npm/xlsx@latest/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@latest/dist/cpexcel.js"></script>

グローバル領域にXLSXオブジェクトが展開されます。
cpexcel.jsは追加の文字コードセットで、これが無いとShift-JISを認識できません。

Node
const XLSX = require("xlsx");

こちらは単純明快。
文字コードセットは全て内包されているので問題ありません。

コード

JavaScript
function parseSheet(blob){
    return new Promise((res, rej)=>{
        const fr = new FileReader();
        fr.addEventListener("load", () => res(fr.result));
        fr.addEventListener("error", () => rej(fr.error));
        fr.readAsArrayBuffer(blob);
    }).then((bin)=>{
        const book = XLSX.read(bin, {
            type: "array",
            codepage: 932
        });

        const sheets = book.SheetNames.map((name)=>{
            const sheet = XLSX.utils.sheet_to_csv(book.Sheets[name], {
                FS: "\u001F"
            }).split("\n").map((row)=>{
                return row.split("\u001F").map((field)=>{
                    if(/^$/.test(field)){
                        return null;
                    }
                    else if(/^true$/i.test(field)){
                        return true;
                    }
                    else if(/^false$/i.test(field)){
                        return false;
                    }
                    else if(/^((|-)((\d{1,3},){0,}\d{3}|\d+)|0(X|x)[0-9a-fA-F]+|0(B|b)[0-1]+)$/.test(field)){
                        const s = field.replace(/,/g, "");
                        const n = Number(s);
                        return Number.isSafeInteger(n) ? n : BigInt(s);
                    }
                    else{
                        return String(field);
                    }
                });
            });

            return {
                [name]: sheet
            };
        });

        return Object.assign(...sheets);
    });
}

各シートを一旦CSVにしてから、各フィールドの型判定を行い二次元配列化しています。

空白セルはnullとなります。
Numberは、桁区切りでカンマが入っていたり16進や2進の表記でも問題ありません。
値が巨大な場合はBigIntとなります。
それ以外の文字列はStringです。

出力されるオブジェクトは、下記のようなデータ構造です。

DataStructure
{
    "シート名": [/*行番号*/][/*列番号*/],
    ...
}

使う

HowToUse.js
const file; // 任意の表計算ファイル

const parsed = parseSheet(file);

console.log(parsed["シート1"][0][0]); // シート1のA1セルに相当

行/列の番号が0スタートなところさえ気を付ければ、特に難しい操作はありません。

これで正規表現での総当りも、条件付きSUMも、フォームへの自動入力も思いのまま!

おわりに

Excelのない朝は
今よりずっと素晴らしくて
すべての歯車が噛み合った
きっとそんな世界だ

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

Vuetifyによる子コンポーネントと親コンポーネント間の双方向データ受け渡しの話

本記事について

下記問題に当たった人向けにv-modelと$emitを使った解決方法を記載してます。
- Vuetifyを使った子コンポーネントと親コンポーネント間のデータ受け渡しで困った方
- 双方向データ受け渡しを実装したけど、「Vue warn]: Avoid mutating a prop directly since ~」って出ちゃった人

問題

例えば、<v-navigation-drawer>をの開閉Toggleを親コンポーネント内で作成したく下記のような実装をしたとする。

  • 子コンポーネント
AppNavigationDrawer.vue
<template>
  <v-navigation-drawer
    v-model="drawer"
    absolute
    temporary
  >
  </v-navigation-drawer>
</template>
<script>
  export default {
    props: {
      drawer: {
        type: Boolean,
        default: false
      }
    }
  }
</script>
  • 親コンポーネント
AppHeader.vue
<template>
  <v-app-bar>
    <v-app-bar-nav-icon
      @click.stop="drawer = !drawer"
    />
  </v-app-bar>
 <navigation-drawer
   v-model="drawer"
 />
</template>
<script>
import NavigationDrawer from '@/components/AppNavigationDrawer'

export default {
  components: {
    AppNavigationDrawer
  },
  data () {
    return {
      drawer: false
    }
  }
}
</script>

結果、子コンポーネント側のdrawerが変化するような操作、例えばNavigationの外側をクリックをすると下記のようなエラーが出る。
スクリーンショット 2019-08-22 16.34.38.png
propsを直接変更するんじゃねぇ!っていうアラートですね。

解決方法

直接変更がダメならemitを使えばいいじゃない。つまり、この辺の話をVuetify Componentに応用すれば良いだけ。

  • 子コンポーネント
AppNavigationDrawer.vue
<template>
  <v-navigation-drawer
    :value="value"
    absolute
    temporary
    @input="$emit('input', $event)"
  >
  </v-navigation-drawer>
</template>
<script>
  export default {
    props: {
      value: {
        type: Boolean,
        default: false
      }
    }
  }
</script>
  • 親コンポーネント
AppHeader.vue
<template>
  <v-app-bar>
    <v-app-bar-nav-icon
      @click.stop="drawer = !drawer"
    />
  </v-app-bar>
 <navigation-drawer
   v-model="drawer"
 />
</template>
<script>
import NavigationDrawer from '@/components/AppNavigationDrawer'

export default {
  components: {
    AppNavigationDrawer
  },
  data () {
    return {
      drawer: false
    }
  }
}
</script>

親からのprops valueを<v-navigation-drawer></v-navigation-drawer>の開閉状態であるvalueにbindする。
子の変化は自身のinput event契機で、親のinputイベントを$emitすることで親コンポーネントへ変化を伝播する。

eventとvalueが定義されているVuetifty Componentであれば何でも応用できそうです。

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

PCの画面をリアルタイム配信 WebRTC 1日目

きっかけ

以前に私がベースを作って友人にソースごと渡したアプリケーションについて、友人から勉強のために色々教えてくれと電話がきた。
「ほにゃららってパッケージはこういうことができてー」
「何行目から何行目はこういうことをしてー」
「何行目で定義している配列はこういう時のためにデータを保存しておくためでー」
なんだかんだ 1 時間半かかった。

画面共有で相手の PC を見ながらならさぞ早かったであろう。
画面共有とまで行かずとも相手の PC 画面さえ見られれば簡単だと思い、世の中に出回る素敵なアプリケーションに目もくれず自分で作れないか調べたところ WebRTC を発見。
最低限の機能をとりあえず作ってみる。

開発環境

  • macOS Mojave
  • Node v12.7.0
  • npm 6.10.0
  • vscode

最終的に作るもの

  • PC 画面を配信
  • 他の PC で配信されている動画を受信する

とりあえず PC 画面を取得したい

まずはこれができないとお話にならないので、PC 画面を取得し video タグで再生までをやってみた。

PC 画面を video タグで再生するコード

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Test</title>
    <style>
      html,
      body {
        padding: 0;
        margin: 0;
      }
      .v {
        width: 100%;
      }
    </style>
  </head>
  <body>
    <video class="v" autoplay></video>
    <script>
      const init = async () => {
        /**
         * video取得
         * @type {HTMLVideoElement}
         */
        const video = document.querySelector('.v')
        try {
          // PC画面のstream取得
          const stream = await navigator.mediaDevices.getDisplayMedia({
            video: true
          })

          // streamをvideoにつなげる
          video.srcObject = stream
        } catch (e) {
          return
        }
      }
      init()
    </script>
  </body>
</html>

案外簡単にいけました。
この html にアクセスすると「どの画面にする?全体?特定のウィンドウ?」って聞かれます。
着目すべき点は以下の部分。

const stream = await navigator.mediaDevices.getDisplayMedia({ video: true })

いままで web サイトとか作ってても出会ったことがないnavigatorさんが登場しました。
ちょっとこれを調べます。

Navigator インターフェース

Navigator - Web API | MDN

Navigator インターフェイスは、ユーザーエージェントの状態や身元情報を表します。スクリプトからその情報を問い合わる、および活動を続けるためにそれら自体を登録することができます。

Navigator オブジェクトは、読み取り専用の window.navigator プロパティを使用して取得できます。

位置情報とか OS とか、ブラウザの外側にある情報にアクセスできる的な?
IE の頃からあったらしい。全然知らなかった。
バッテリー情報も見れるらしいですよ。
ただ、ブラウザによって実装が結構ぐちゃぐちゃみたい。

navigator.mediaDevices (MediaDevices オブジェクト)

Navigator.mediaDevices - Web API | MDN

さて、Navigator がどんなものかちょっと知ったところでお次はnavigator.mediaDevicesですね。
これは MediaDevices オブジェクトを返すらしいです。

MediaDevices はカメラ映像・マイクの音声・PC 画面映像とかメディア入力装置を紹介してくれる案内役っぽいですね。

例えばnavigator.mediaDevices.getUserMedia(constraints)とすればconstraintsで指定したメディア入力装置からのストリームが取れるそうです。

メディア入力装置の指定ってどうやるねんって思ったらちゃんとありました。
mediaDevices.enumerateDevices()で PC に接続されている全てのメディア入力装置を取得でき、その中から指定したい装置の ID を使用するそうです。

constraintsは他にも映像のサイズ(width, height)とかも設定できるそうで。

ブラウザってそういうのできるんだなーってびっくりしました(無知

navigator.mediaDevices.getDisplayMedia(constraints)

先ほどはnavigator.mediaDevices.getUserMedia(constraints)の説明をしましたが、
今回使用するのはnavigator.mediaDevices.getDisplayMedia(constraints)です。

これは PC 画面のストリームを取得できるものです。
constraintsにどの画面にするか、あるいはどのウィンドウにするかを設定できます。
ウィンドウ単位の映像までとれるなんて!
ちなみに指定しなかったらブラウザがどの画面にする?って聞いてくれます。
音声はまだとれないっぽい?ですね。

さっきからストリームストリームって言ってるけど何それ?って方へ

navigator.mediaDevices.getUserMedia(constraints)navigator.mediaDevices.getDisplayMedia(constraints)で得られるオブジェクトは MediaStream というもので、ざっくりいうと映像・音声データのことです。

これを video タグに渡せば映像をブラウザ内で見れます。

配信したい

配信するには WebRTC っていう技術?API?を利用するそうです。

WebRTC - Wikipedia
Web RTC API - Web API | MDN

根幹には Peer to Peer(以下 P2P)が使われているそうです。
P2P はその昔 Winny で一躍有名になったことだけは覚えています。

せっかくなので P2P についてちょっと調べてみました。

P2P

Web 界隈の通信というとサーバー・クライアントの通信を自然と考えます。

サーバー・クライアント方式で

  • サーバーが 1 つあり、そこに複数のクライアントがアクセスしてサーバーとやりとりする。
  • クライアント同士でやりとりするにはサーバーに仲介をお願いする

のようにクライアントは基本的にサーバーとしかやりとりしない中央集権型の通信方式です。

しかし P2P は地方分散型の通信方式で、それぞれがサーバーになったりクライアントになったりします。

例えば

  • A さんがとあるファイルを要求した場合、それを持っている B さんがサーバー役になってファイルを供給する
  • また別のファイルを C さんが要求した場合、A さんが偶然持っていたのでサーバー役になってくれる

みたいな感じだそうです。

P2P についてだけで余裕で本が書けるし、ブロックチェーンだとかそういったものにも使用されていて
この界隈は今けっこう盛り上がってるそうです。
IPv6 が主流になって各端末がそれぞれ IP を持つようになったら爆発的に機能しそうだなーっと素人目で見てました。

さて、問題はどうやってブラウザで P2P 通信をするのかです。
ブラウザは基本的にただのクライアントアプリケーションでしかないと思っていたので、実装方法が想像もつきません。

ということでそれを可能にしている WebRTC の技術を見てみます。

WebRTC

P2P を用いてブラウザ間でのデータのやりとりをする仕組みだそうで。
以下の 2 つが重要らしいです。

  • SessionDescriptionProtocol(SDP)
  • Interactive Connectivity Establishment(ICE)

SDP はデータの情報とか IP アドレスとかポート番号が書いてある文字列だそうです。
ICE は通信経路情報だそうです。

SDP をブラウザ間で交換して、お互い ICE を追加すれば通信できる経路が見つかってコネクションが成立するそうです。

ようわからんので、頑張って雰囲気をつかみたいところです。

通信の雰囲気をつかむ

まずは通信をしたいブラウザ同士でお互いのことを知る必要がありそうです。
IP アドレスとかポート番号とかですね。
PC が自分の IP アドレスとして認識しているのはルーターが割り当てるプライベート IP アドレスです。
(ブラウザ A の PC: 192.168.1.2、ブラウザ B の PC: 192.168.10.5 みたいな)
こんなものを知ったところで相手にデータを送ることはできなさそうです。

これを解決する方法はルーターの外側にいる人から教えてもらうのが手っ取り早いです。

ブラウザ A がルーターの外側の人に自分がどう見えるかを要求すれば、

192.168.1.10(プライベート IP) > ルーター(NAT) > 123.123.123.123(グローバル IP) > 外側の人

通信は適当な空いているポートを開いてレスポンスを受け取れるようにしているのでポートもわかります。
外側の人から「君は 123.123.123.123 でポート 60000 番って見えるよ!」って教えてもらえます。

通常 http はステートレスなのでデータの受信が終了したらこのポートを閉じてしまいますが、これを開けっぱにしておけば通信ができそうなことはわかります。

ブラウザ B でも同様にして外側の人から「君は 111.111.111.111 でポート 55555 番って見えるよ!」と教えてもらいます。

この外側の人のことを STUN サーバーと呼ぶそうです。
実際にはそこにたどり着くまでの ICE を STUN サーバーは教えてくれるそうです。

こんなような情報をお互い交換したら、P2P 通信ができそうな雰囲気はつかめました。

でもおかしくないです?
通信を可能にするための情報の交換はどうやるんですかね?

シグナリングサーバー

P2P 通信が確立する前段階の通信をサポートするサーバーだそうです。
WebRTC のためにはサーバークライアント方式でデータをやりとりする必要が出てきちゃうそうです。

上述の SDP をやりとりします。

これにうってつけなのが WebSocket です。
リアルタイム双方向通信でブラウザ同士の SDP なり ICE なりを相互に送りあってもらいましょう。

処理の流れを考える

配信側 受信側 使う API
PC 画面のストリームを取得しておく MediaStream
配信要求を送信する
配信要求を受ける
コネクションを作成する RTCPeerConnection
コネクションにストリームを設定する RTCPeerConnection.addTrack()
コネクションのオファーを作成する RTCPeerConnection.createOffer()
オファーを自身に設定する RTCPeerConnection.setLocalDescription()
STUN サーバーへのアクセス開始
ICE の候補(ICE candidate)を順次取得する RTCPeerConnection.onicecandidate
ICE candidate を全て取得したら SessionDescription を送信
オファーを受ける
コネクションを作成する RTCPeerConnection
コネクションの通信先としてオファーを設定する RTCPeerConnection.setRemoteDescription()
コネクションのアンサーを作成する RTCPeerConnection.createAnswer()
アンサーを自身に設定する RTCPeerConnection.setLocalDescription()
STUN サーバーへのアクセス開始
ICE の候補(ICE candidate)を順次取得する RTCPeerConnection.onicecandidate
ICE candidate を全て取得したら SessionDescription を送信
アンサーを受ける
コネクションの通信先としてアンサーを設定する RTCPeerConnection.setRemoteDescription()

こんな感じでやっていきます

実装開始!

出来上がりを git にアップいたしました。
kotazuck/webrtc-test

シグナリングサーバー

/**
 * シグナリングサーバー(WebSocketサーバー) + Webサーバー
 */
// Express
const express = require('express')
const app = express()

// publicディレクトリを公開
app.use(express.static(__dirname + '/public'))

const http = require('http')
const server = http.createServer(app)

// WebSocketサーバーにはsocket.ioを採用
const io = require('socket.io')(server)

// 接続要求
io.on('connect', socket => {
  console.log('io', 'connect')
  console.log('io', 'socket: ', socket.id)

  // 受信側からの配信要求を配信側へ渡す
  socket.on('request', () =>
    socket.broadcast.emit('request', { cid: socket.id })
  )

  // 配信側からのオファーを受信側へ渡す
  socket.on('offer', ({ offer }) => {
    socket.broadcast.emit('offer', { offer })
    // 配信側の接続が切れた場合にそれを受信側へ通知する
    socket.on('disconnect', () => socket.broadcast.emit('close'))
  })

  // 受信側からのアンサーを配信側へ渡す
  socket.on('answer', ({ answer }) =>
    socket.broadcast.emit('answer', { cid: socket.id, answer })
  )
})

server.listen(55555)

配信側

;(async () => {
  // シグナリングサーバーであるWebSocketサーバーに接続
  // 今回はsocket.ioを採用
  const socket = require('socket.io-client')('http://localhost:55555')

  /**
   * RTCPeerConnectionをクライアントごとに格納する変数
   * keyをクライアントID(ソケットID)として保存する
   */
  const connections = {}

  /**
   * PC映像streamを取得
   * @type {MediaStream}
   */
  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true })

  // ソケットサーバー疎通確認
  socket.on('connect', () => console.log('socket', 'connected'))

  // 配信要求を受ける
  // Client ID (cid)を受け取りコネクションを作成する
  socket.on('request', ({ cid }) => sendOffer(cid))

  // アンサーを受ける
  socket.on('answer', async ({ cid, answer }) => {
    if (cid in connections) connections[cid].setRemoteDescription(answer)
  })

  /**
   * オファーを送信する
   *
   * @param {string} cid Client ID
   * @return {void}
   */
  async function sendOffer(cid) {
    // コネクションの設定
    const pcConfig = {
      // STUNサーバーはGoogle様のものを利用させていただく
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    }

    // コネクションの作成
    const peer = new RTCPeerConnection(pcConfig)

    // cidをキーとしてコネクションを保存
    connections[cid] = peer

    // コネクションにストリームを設定
    stream.getTracks().forEach(track => peer.addTrack(track, stream))

    // ICE candidateを取得イベントハンドラ
    peer.onicecandidate = evt => {
      // evt.candidateがnullならICE Candidateを全て取得したとみなしてオファーを送信
      if (!evt.candidate)
        socket.emit('offer', { offer: peer.localDescription, cid })
    }

    // オファーを作成
    const offer = await peer.createOffer()

    // オファーを自身に設定
    // STUNサーバーへアクセスが始まり、onicecandidateが呼ばれるようになる
    await peer.setLocalDescription(offer)
  }
})()

受信側

;() => {
  // シグナリングサーバーであるWebSocketサーバーに接続
  // 今回はsocket.ioを採用
  const socket = require('socket.io-client')('http://localhost:55555')

  /**
   * @type {HTMLVideoElement}
   */
  const video = document.querySelector('video')

  video.addEventListener('click', evt => {
    if (video.paused) video.play()
    else video.pause()
  })

  /**
   * コネクションを保存しておく用
   *
   * @type {RTCPeerConnection}
   */
  let connection = null

  // ソケット接続で配信要求する
  socket.on('connect', () => socket.emit('request'))

  // アンサーを受ける
  socket.on('offer', async ({ offer }) => sendAnswer(offer))

  // closeがきたらコネクションを切ってvideoも止める
  socket.on('close', () => {
    if (connection) {
      video.pause()
      video.srcObject = null
      connection.close()
      connection = null
    }
  })

  /**
   * アンサーを送信する
   *
   * @param {RTCSessionDescription} offer
   * @return {void}
   */
  async function sendAnswer(offer) {
    // コネクションの設定
    const pcConfig = {
      // STUNサーバーはGoogle様のものを利用させていただく
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    }

    // コネクションの作成
    const peer = new RTCPeerConnection(pcConfig)

    // コネクションを保存
    connection = peer

    // 配信イベントハンドラ的な?
    peer.ontrack = evt => {
      console.log('ontrack')

      // streamを設定
      video.srcObject = evt.streams[0]
    }

    // ICE candidateを取得イベントハンドラ
    peer.onicecandidate = evt => {
      // evt.candidateがnullならICE Candidateを全て取得したとみなしてアンサーを送信
      if (!evt.candidate)
        socket.emit('answer', { answer: peer.localDescription })
    }

    // コネクションの通信先としてオファーを設定
    await peer.setRemoteDescription(offer)

    // アンサーを作成
    const answer = await peer.createAnswer()

    // アンサーを自身に設定
    await peer.setLocalDescription(answer)
  }
}

やっていることは全てコメントに書いているからお分かりかとは思います。

感想

無事、画面の配信が完了しました。
案外と手軽なサイズ感でできました。

ちなみに今回のシグナリングは Vanilla ICE という方式を用いています。

  • Vanilla ICE
    • 全ての ICE Candidate(経路情報の候補)を取得してから SDP の交換を行う方式
    • 多分こっちの方が記述がスッキリします
    • バニラアイスって読み方でいいのかな
  • Trikle ICE
    • 先に初期状態の SDP を交換し、ICE を取得するたびにそれを送信し追加していきます。
    • 接続できる経路が見つかった時点で接続されるので、Vanilla ICE より早く繋がるらしいです。
    • 記述が複雑というか面倒っぽいです

配信・受信でコネクション周りは共通した処理もあるので、まとめちゃえばもっと簡単かも。

WebSocket で room 分ければ簡単な生配信くらいはできそう。

受信側の ontrack で video.play()が効かなかったのが悲しみ。oncanplay でやったりしてみたけどダメでした。
なんでだっけ?前にも引っかかったような気がする。

あとは文字のチャット機能、画面配信+マイク音声にして声をいれたりすればそこそこ面白いんじゃね?

P2P ってサーバー負荷をあまり考えなくて良いのが幸せ。

2 日目は何をやろう

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

MakeShop独自ドメイン利用でAnalyticsのCookieが途切れる問題

やりたかったこと

MakeShopのECサイトを独自ドメインで運用しているショップでGoogleAdwordsの効果を確かめたい

問題点

MakeShopでは、かなりの高額プランのものでない限り、購入画面でmakeshopドメインに遷移してしまう。それ自体はいいのだが、その際に本来引き継がれるはずのCookieが切れてしまい、広告効果が測れない。

原因

MakeShop側のJavaScriptの記述順に問題ありの模様

view/asset/system-1-1-6-4...
if (pageData.attr('data-order-google-analytics-enabled') === 'Y') {
 ga('linker:decorate', document.getElementById('makeshop-form-common-order'));
   }
if (data.service_type == 'amazon') {
  $('#makeshop-form-common-order 
  input[name=amazon_login_type]').val('amazon_basket_order');
  }

 $('#makeshop-form-common-order').attr('action', data.order_url);
 $('#makeshop-form-common-order').submit();

本来はこの最初のif行が最後に来るべきところ。。。とはいえ、MakeShopに文句言っても仕方ないので、

解決策

MakeShopの「アクセス解析用のタグ」という追加入力のところにこんな感じで入れとけばOK
「AW-XXXXXXXXX」部分は適宜差し替え。

<script async src="https://www.googletagmanager.com/gtag/js?id=AW-XXXXXXXXX"></script>
<script>
 window.dataLayer = window.dataLayer || [];
 function gtag(){dataLayer.push(arguments);}
 gtag('js', new Date());
 gtag('config', 'AW-XXXXXXXXX');
window.addEventListener('load', function(){
   var addToCartForm = document.getElementById('makeshop-form-common-order');
   if(!addToCartForm || !window.location.pathname.includes('\/cart'))return;
   addToCartForm.onsubmit = function(e){
      ga('linker:decorate', document.getElementById('makeshop-form-common-order'));
   };
});
</script>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【メモ】Cookie(野性爆弾ではない)

COOKIEデータ

名前と値のセットで指定する。
EX) name=kawashima;

有効期限

以下の2種類のいずれかの方法でセットする。両方指定していた場合はmax-ageが優先される。
未設定の場合はセッション切れとともに消滅する。

expires

消滅までの日付をUTC形式でセットする。

365日後に消滅する例

expires='Fri, 21 Aug 2020 04:41:48 GMT';

UTC形式取得方法(javascript)

1年後の日付をUTCで取得する関数です。
コピペする場合、関数名や引数とかは適宜変えて下さい。

sample.js
function cookieDeadlineUTC(days=365){
  var expire = new Date();
  expire.setTime(expire.getTime() + 1000*3600*24*days);
  return expire.toUTCString();
}

max-age

消滅までの秒数でセットする(IE6,7,8は非対応)

10分後に消滅する例

max-age=600;

有効範囲

セットしたCOOKIEの有効範囲をセットする。

domain

未設定:現ドメイン下で有効になる。ただしサブドメインは対象外。
設定:現ドメイン、サブドメイン下で有効になる。

ketabawo.asia内で有効化する例

domain=ketabawo.asia;

path

複数指定は半角カンマで区切れば良さそう(要調査)でも基本はドメインルート(未設定or[/])指定でOK。
未設定:対象ドメイン下で有効となる。
設定:セットしたディレクトリ下で有効となる

/hogeディレクトリ下で有効にする例

path=/hoge;

まとめ

10日間、ドメインgoogle.com内で有効なクッキー名「user」、値「premium」をセットする例

sample.js
document.cookie = 'user=premium; max-age=86400; domain=google.com;';

pathは未設定でルートとなるため指定しない

削除するには?

max-ageを0にすれば消えます。
もし消えない場合はdomainやpathも空の値でやってみてください。

上記cookieを消す例

sample.js
document.cookie = 'user=; max-age=0;';
//もしくは
document.cookie = 'user=; max-age=0; domain=; path=;';

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

機械学習をブラウザ上で手軽に行えるStackMLのおぼえがき

StackMLとは?

ブラウザ上で機械学習が行えるサービスで、顔検出、画像分類、ポーズ検出などが行え、無料で利用することができます。

StackMLを使うには

  • 利用登録を行う。
    StackMLのサイトのGET STARTED FREEをクリックしてアカウントを作成します。外部アカウントを使用してアカウントを作成することもできます。使用できる外部サービスのアカウントとして、TwitterとGoogleがあります。

アカウントが作成できたら

GET STARTED FREEとなっていた部分が、GO TO DASHBOARDとなります。
ダッシュボードに移動して、サービスを利用します。

ダッシュボードに移動

元からあるモデルを使用するSelect a modelと、自分で機械学習モデルを作成できるTrainingという項目があります。

画像分類を使ってみる

StackMLのダッシュボードにあるModelsのImage classificationを使用します。

INPUT SOURCE のドロップダウンメニューからImage Uploadを選択し、Choose Fileをクリックし、分類したい画像をアップロードします。アップロード後、予測ボタンのPredictをクリックして予測することができます。

皇帝ペンギンの画像を分類にかけたところ、99.2%の確立でキングペンギンとなりました...。
4.png

機械学習を行ってみる

ダッシュボードのTRAINから、機械学習を行います。
Model Nameには、学習させる画像の名前などを指定します。Model Typeには、学習させたいモデルを選択します。

MobileNet classifiernumeric classificationnumeric regressionから選べますが、画像分類に使用するので、GoogleのMobileNetを使用します。

学習させたい画像をフォルダにまとめて、アップロードします。ここでは、エンペラーペンギンとキングペンギンの画像を学習させます。Penguinフォルダの中に、emperorとkingフォルダを作成し、その中に画像を入れます。
10.png
画像のアップロード後、Trainボタンで機械学習を行えます。学習が完了した後は、ポップアップが表示され、ダウンロードとモデルのテストが可能です。11.png

機械学習で作ったモデルを使用してみる

MODELSMY MODELに先ほど学習させたモデルが選択できるので、Image classificationで作成したモデルをテストします。

画像を選択してアップロードし、分類させます。12.png
結果として、0の確立が72.9%となりました。学習させたフォルダの0番目、つまりアルファベット順ではエンペラーペンギンのフォルダということになります。つまり、エンペラーペンギンの確立が72.9%ということになります。

最後に

ブラウザ上で機械学習が手軽にできる良いサービスだと思いました。SONYのNeural Network Consoleのようにブラウザで機械学習ができるサービスはありますが、有料が多いので、無料で利用できるのは試しにやってみようと思えます。
StackMLはAPIが公開されているので、TensorFlowでコードを書くよりも低難易度で機械学習アプリを作ることができ、機械学習のハードルを下げる良いサービスだと思います。

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

webpackを調べてみた?

はじめに

Reactなど今どきなフロントエンド開発を学ぶときに前提としてwebpackの知識が必要だと感じたのでUdemyと書籍でwebpackについて学んでみました

モジュールバンドラーwebpackを1日で習得!しかもフルスクラッチでインストールからカスタマイズまでの手順を理解する

速習webpack 速習シリーズ

webpackとは

  • モジュールバンドラーの一つ
  • nodejs上で動作するコマンドラインツール
  • 他にはBrowserifyRequireJSなど。webpackが一番利用されている
  • webpack単体だとjsのみをバンドル。ローダをインストールすることでスタイルシートや画像などもバンドルできるようになる

モジュールバンドラーとは

  • JavaScriptコード、CSS、画像などのファイル(モジュール)を一つのファイルにまとめる(bundle)
    • webpackではJavaScriptファイル以外のファイルをバンドルするにはLoader機能を用いる
  • モジュールバンドラーと一緒にトランスコンパイラを用いることでTypeSctriptなどのaltJs言語をコンパイルした上でバンドルすることも可能

なぜモジュールバンドラーが必要なのか?

  • 大規模な開発になる場合に複数のJavaScriptコードの依存関係が複雑になってしまうので
    • モジュールバンドラーが自動的に依存関係を解決する
  • ファイルをまとめるのでリクエストの回数を減らせる
    • HTTP/1.1環境では同時接続数が制限されるので、転送効率が向上する
  • 大規模開発に向いている
    • 最終的にモジュールバンドラーで依存関係を気にせずモジュールを一つにできるので、モジュール単位に細かく分割して開発することができる

タスクランナーまたはビルドツールとは

  • コンパイル、圧縮などのタスクを実行するためのツール。Grunt、gulpなどから webpackを使う

webpackの実行

  • npm webpack
    • webpack.confi.jsを参照して実行
  • npx webpack --open --mode development

モード付き実行

npx webpack --mode productionで本番環境の実行(圧縮あり)。

開発時の便利ツール

webpack-dev-server

  • webpackと連動する開発サーバー
  • jsを変更したら自動でバンドルしてページを表示してくれる

package.jsonのscripts

タスクを登録できて、npm run タスク名で実行できる

cssのバンドル

  • css-loader
    • cssを読み取ってJavaScriptのオブジェクトとして扱えるようにする
    • import style from 'cssファイル'
  • style-loader
    • css-loaderで読みとったスタイルをstyleタグに書き込む
    • document.body.classList.add('css-loaderで読み込んだクラス')

画像のバンドル

  • url-loader
    • 画像をBase64にエンコード(変換)して直接HTMLファイルに埋め込む方法
    • HTTPリクエスト数を減らすことができる
  • file-loader
    • url-loaderでHTMLに直接埋め込みすぎるとHTMLが肥大化してしまうので、一定大きさの画像は別ファイルとして切り出すようにする

例)2KB以上はimagesに切り出す

 22          test: /\.(jpe?g|png|gif|svg|ico)$/i,
 23          loader: 'url-loader',
 24          options: {
 25            limit: 2048,
 26            name: './images/[name].[ext]'
 27          }

sassのバンドル

  • sass-loader
    • SassのコードをコンパイルしてCSSに変換。css-loaderとstyle-loaderとのセットで利用する。

Babel

  • babel-loader
    • バンドル前にトランスコンパイルを挟むツール
  • babel-core
    • babel本体
  • babel-preset-env
    • ES2015のコードをトランスコンパイル
    • .babelrcに記述して設定を有効にする必要がある
  • babel-preset-react
    • React独自のJSXをコンパイル

babel-loaderの設定
babel-loaderが.babelrcを見てコンパイルする


 14      rules: [
 15        {
 16          test: /\.jsx?$/,
 17          exclude: /node_modules/,
 18          loader: "babel-loader"
 19        },

プラグイン

html-webpack-plugin

  • html-loaderでJSでhtmlを利用できるようにする。
  • JavaScriptを呼び出すためのトップページのhtmlを自動で用意。テンプレートを用意する.

mini-css-extract-plugin

  • CSSファイルのサイズが大きい場合にバンドルせずに別ファイルに分けたい場合に使用する

uglifyjs-webpack-plugin

  • console.logの自動削除

optimize-css-assets-webpack-plugin

  • スタイルシートの圧縮。プロダクションモードでも圧縮されないので圧縮する

マップファイルの作成

  • バンドル後のエラーを追跡しやすいようにバンドル前後でのソースコードの対応ファイル。コンソールでエラーを確認したときに、バンドル前のファイルのソールコードの場所を教えてくれる
  • webpack.config.jsにdevtoolの設定を追加

補足:git

  • git merge --no-ff - -m 'merge branch into master'
    • ハイフンは直前のブランチ
    • masterブランチに変更がない場合に変更ブランチをそのままマスターにする(fast-forward(早送り)マージ)が--no-ffで新しくマージコミットを作成する方法。変更ブランチはそのまま残るので履歴を追いかけやすくなる
  • git push origin HEAD
    • カレントブランチをリモートの同名ブランチにプッシュできる
  • git diff
    • まだインデックスにaddしていないものを表示
  • git diff --cached
    • インデックスとHEADとの差分

参考

https://ics.media/entry/17376/

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

小売物価統計でガソリン価格を調べる

(初出: 2018-09-22)

仕事で直近のガソリン価格を調べる必要があった。なぜか「ガソリンスタンドによって値段が違うからなあ」という反応が多いお題だが、こんなものはさくっと統計を引いてやっつけるべきものだ。そこで小売物価統計からもってくることにする。今はこうした基礎データがネットで簡単に入手できる。e-Stat を見に行こう。一回だけならリンクをたどったり検索したりで十分だが、毎月やるくらいならプログラムを書くべきだろう。e-Stat では API が用意されておりデータを引くのは簡単だ。

ユーザー登録とアプリケーション ID を取得する

手作業で登録する。メールアドレスが必要。

アプリケーション ID を取得する

手作業で登録する。40桁の16進数が得られるようだ。以下 <APPID> とする。

小売物価統計からガソリン価格を取得する

統計表情報取得

提供データの一覧 を見ると小売物価統計調査の政府統計コードは 00200571 であるとわかるので、まずこいつに API からアクセスする場合の統計データ ID を調べる。

$ curl https://api.e-stat.go.jp/rest/2.1/app/getStatsList?appId=<APPID>&statsCode=00200571

結果は XML で得られ、これを覗くと小売物価統計の statsDataId が 0003105586 であることがわかる。

メタ情報取得

データ抽出条件には地域・品目・年月のコード (ID) が必要なので、その一覧を取得する。

$ curl https://api.e-stat.go.jp/rest/2.1/app/getMetaInfo?appId=<APPID>&statsDataId=0003105586

こちらも結果は XML で得られる。今回必要なのは長野県松本市のガソリン価格だ。松本市の地域コードは 20202、自動車ガソリンの品目コードは 07301 であることがわかる。地域コードは地方公共団体コード (チェックディジットなし) と同一だった。

統計データ取得

統計データ本体は、XML/JSON/JSONP/CSV のいずれかの形式で取得できる。全地域全期間全品目のデータを XML で落とすのならばこうする。

$ curl https://api.e-stat.go.jp/rest/2.1/app/getStatsData?appId=<APPID>&statsDataId=0003105586

松本市のデータだけ取得するには、パラメーターに cdArea=20202 を追加する。次の例では CSV での取得を行う。ただし CSV とはいっても冒頭にかなりの量のヘッダが付く。

$ curl https://api.e-stat.go.jp/rest/2.1/app/getSimpleStatsData?appId=<APPID>&statsDataId=0003105586&cdArea=20202

さらに品目を絞り、松本市のガソリン価格だけ取得するため、パラメーターに cdCat01=07301 を追加する。

$ curl https://api.e-stat.go.jp/rest/2.1/app/getSimpleStatsData?appId=<APPID>&statsDataId=0003105586&cdArea=20202&cdCat01=07301

JSON でデータを得るには、エンドポイントを若干変更する。

$ curl https://api.e-stat.go.jp/rest/2.1/app/json/getStatsDataappId=<APPID>&statsDataId=0003105586&cdArea=20202&cdCat01=07301

取得したデータを解釈する

Python で処理する

使い慣れた Python で最新のガソリン価格を得よう。

$ curl -s "https://api.e-stat.go.jp/rest/2.1/app/json/getStatsData?appId=<APPID>&statsDataId=0003105586&cdArea=20202&cdCat01=07301" >result.json
$ python3 -q
>>> import json
>>> result = json.load(open("result.json"))
>>> price_history_raw = result["GET_STATS_DATA"]["STATISTICAL_DATA"]["DATA_INF"]["VALUE"]
>>> price_history = sorted([(e["@time"][:4] + e["@time"][6:8], e["$"]) for e in price_history_raw])
>>> price_history[-1]
('201808', '154')

簡単だ。

Javascript で処理する

JavaScript でも同じように書ける。ここではファイルからではなく直接 e-Stat から読みだしている。

const appid = "<APPID>";
const statsdataid = "0003105586";
const cdarea = "20202";
const cdcat01 = "07301";
var url = `https://api.e-stat.go.jp/rest/2.1/app/json/getStatsData?appId=${appid}&statsDataId=${statsdataid}&cdArea=${cdarea}&cdCat01=${cdcat01}`;
var request = new XMLHttpRequest();
request.open("GET", url);
request.responseType = "json";
request.addEventListener("load", (e) => {
    var prices = e.target.response["GET_STATS_DATA"]["STATISTICAL_DATA"]["DATA_INF"]["VALUE"];
    var yyyymm = prices[0]["@time"];
    yyyymm = yyyymm.slice(0, 4) + yyyymm.slice(6,8);
    var price = prices[0]["$"];
    console.log(yyyymm, price);
});
request.send();

これまた簡単。

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

javascriptのスコープに関するちょっとした実験

(function(global ){

    function a(){
        console.log("execute a");
    }
    var c = {
        message: "this is message",
        doSomething1: function(){
            a();
        },
        doSomething2: function(){
            this.doSomething1();
        },
        doSomething3: function(){
            c.doSomething1();
        }
    }
    global.GlobalObject = c;
})(this);

a(); // error
c.doSomething(); // error
GlobalObject.doSomething1(); // "a"
GlobalObject.doSomething2(); // "a"
GlobalObject.doSomething3(); // "a"

console.log(c.message); // error
console.log(GlobalObject.message); // "a"

外から this を渡して使うのは node環境とブラウザ実行環境他でも使えるようにするため(だと思う)
つまりブラウザで実行される場合、 "global" は "window" になるので 実際は window.GlobalObject が作られる。

参考にしたのは vue.js

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

できるエンジニアはconsole.log()を上手く使いこなしている。(初心者向け)

はじめに

できるエンジニアはconsole.log()を使いこなしているとのこと。
未経験から上がったような人向けです。
console.log()の配置場所、中身の書き方など参考すべき記事やアドバイスありましたら教えてください。

個人でのやりやすさが肝だと思うのでいろんな人の意見を聞きたいです。
node.jsを触っていて、フロントはさっぱりです。

デバック時のconsole.logの書き方
 

// DBからデータ取得時にはエラーが起きにくいので、データ取得の関数の後に`console.log()`を。
// DB更新時のエラーは起きやすいから、DB変更の関数の前後にconsole.log()を仕込む。 
result1 = getDate();
console.log('getDate: ' result);

console.log('addDate');
result2 = addDate();
console.log('addedDate: ' result2);


//簡潔な書き方
console.log(`checkdata: ${result}`)  console.log('checkdata: ', result) 

// 変数の型がわかる。
console.log(type of 変数) //stringなど。

//result1,2みたいな書き方は意図が読めず関数としては最悪なので実際には書かないでください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

package.json について概要だったり、プロパティだったり

package.jsonってなんだ?となったので、備忘録。随時更新します。

関連用語

  • Node.js・・・サーバサイドで動く javascript
  • npm・・・javascript のモジュールを管理するツール。ruby でいう bundler のようなもの。
  • yarn・・・npm と同じ javascript のモジュールを管理するツール。npm よりインストール速度が早いらしい

概要

パッケージの依存関係を記した json ファイル。rails で使用する Gemfile のようなもの

作り方

npm init でカレントディレクトリに作成される。

結果

カレントディレクトリ jstest で npm init を打って、何も入力せずに Enter 連打すると以下のようになる

コマンド結果
$ npm init
・・・略・・・
name: (jstest)
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
/package.json:

{
  "name": "jstest",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

Is this ok? (yes) yes
package.json
{
  "name": "jstest",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

中身

name

パッケージ名。必須。name と version で一意にする。他のライブラリと被ったらダメ

"name": "jstest"

version

パッケージのバージョン。必須。パッケージ更新時は version も更新する

"version": "1.0.0"

description

パッケージについての説明

"description": ""

main

パッケージ内で最初に呼ばれるモジュール。今回で言えば、パッケージ jstest を require した時に、index.js 内で export しているオブジェクトが返る

"main": "index.js"

scripts

シェルスクリプト、エイリアスコマンドを指定できる
test、start などの予約語を key 名に指定した場合、npm key 名でコマンド実行できる。(他の予約語はなにがあるか分からない・・・)

"scripts": {
    "test": "echo test"
  }

予約語以外を key 名に指定した場合、npm run key 名でコマンド実行できる。devDependencies にモジュール指定しておけば、PATH が自動的に通る

"scripts": {
    "webpack": "node_modules/.bin/webpack -d"
  }

と、書かなくてもモジュール指定しておけば以下のように書ける

"scripts": {
    "webpack": "webpack -d"
  }
"devDependencies": {
    "webpack": "^4.34.0"
 }

author

著者を一人書く。プロジェクト開発ではあまり気にしなくていいかも

author: "Barney Rubble <b@rubble.com> (http://barnyrubble.tumblr.com/)"

license

パッケージのライセンス。これも気にしなくていいかも。ISC はゆるいライセンスで、コピー、改変、配布などを許可するものらしい

"license": "ISC"

参考

公式ドキュメント
package.json の scripts についての備忘録

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

package.json について概要だったり、詳細だったり

package.jsonってなんだ?となったので、備忘録。随時更新します。

関連用語

  • Node.js・・・サーバサイドで動く javascript
  • npm・・・javascript のモジュールを管理するツール。ruby でいう bundler のようなもの。
  • yarn・・・npm と同じ javascript のモジュールを管理するツール。npm よりインストール速度が早いらしい

概要

パッケージの依存関係を記した json ファイル。rails で使用する Gemfile のようなもの

作り方

npm init でカレントディレクトリに作成される。

結果

カレントディレクトリ jstest で npm init を打って、何も入力せずに Enter 連打すると以下のようになる

コマンド結果
$ npm init
・・・略・・・
name: (jstest)
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
/package.json:

{
  "name": "jstest",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

Is this ok? (yes) yes
package.json
{
  "name": "jstest",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

中身

name

パッケージ名。必須。name と version で一意にする。他のライブラリと被ったらダメ

"name": "jstest"

version

パッケージのバージョン。必須。パッケージ更新時は version も更新する

"version": "1.0.0"

description

パッケージについての説明

"description": ""

main

パッケージ内で最初に呼ばれるモジュール。今回で言えば、パッケージ jstest を require した時に、index.js 内で export しているオブジェクトが返る

"main": "index.js"

scripts

シェルスクリプト、エイリアスコマンドを指定できる
test、start などの予約語を key 名に指定した場合、npm key 名でコマンド実行できる。(他の予約語はなにがあるか分からない・・・)

"scripts": {
    "test": "echo test"
  }

予約語以外を key 名に指定した場合、npm run key 名でコマンド実行できる。devDependencies にモジュール指定しておけば、PATH が自動的に通る

"scripts": {
    "webpack": "node_modules/.bin/webpack -d"
  }

と、書かなくてもモジュール指定しておけば以下のように書ける

"scripts": {
    "webpack": "webpack -d"
  }
"devDependencies": {
    "webpack": "^4.34.0"
 }

author

著者を一人書く。プロジェクト開発ではあまり気にしなくていいかも

author: "Barney Rubble <b@rubble.com> (http://barnyrubble.tumblr.com/)"

license

パッケージのライセンス。これも気にしなくていいかも。ISC はゆるいライセンスで、コピー、改変、配布などを許可するものらしい

"license": "ISC"

参考

公式ドキュメント
package.json の scripts についての備忘録

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

Chrome でなるべく早くスクリーンキャプチャを撮る拡張機能を作りましたが想像以上に大変でした

ついこの前、Chrome でスクリーンキャプチャをできる限り素早く取得する拡張機能を作成しました。

Immediate Shot サムネイル

Immediate Shot

ある日、会社でディレクターから Chrome で手軽にスクリーンキャプチャを撮る方法はないかと尋ねられ、確か、最近の Chrome だったら……と、開発者ツールを開いてからキャプチャを撮る方法を提案しました。

しかし、ショートカットがあるならまだしも、画面ごとに F12 キーを押して、詳細機能を呼び出し……なんてやるのは、大量のキャプチャを撮らねばならないときは結構な手間です。

聞けば、ちょっと前までは Firefox にワンクリックで画面のキャプチャを撮ってくれる拡張機能があったということだったのですが、Firefox のアップデートによりその拡張機能は使えなくなり、現在 Chrome に公開されている拡張機能も試したものは全て何かしらのワンクッションが挟まってるとのことでした。

なるほど、それだったら趣味も兼ねて Chrome の拡張機能開発に挑戦してみよう、と思い至り、開発に着手しました。

なぁに、拡張機能独自のお約束を理解するのに手間取っても、30分もあればおちゃのこさいさい、Chrome ごと木っ端微塵にしてくれるわ、と。

え、意外とこれ難しくない?

というわけで、この記事はサイト全体のキャプチャを撮るのが意外と面倒だったのでその方法を Chrome の拡張機能として作る方法の共有となっております。

拙い拡張機能ではありますが、Chrome の拡張機能作成の流れを流し読みする目的にもどうぞ。

なお、コードは TypeScript によって開発しましたので、この記事の説明も TypeScript で行います。途中まで Babel で作成してたんですが、よく考えたら Chrome でしか動かさないよねこれ??と気づいたので書き直しました。型も重要ですけど、皆さんも着手前の要件定義・技術選定はしっかりと行いましょう。くわばらくわばら。

先に拡張機能の紹介

まだ改修の余地はありますが、現在実際に動くコードは GitHub に公開しています。

https://github.com/Go-Noji/Immediate-Shot

拡張機能をインストールすると Chrome のアドレスバー横に拡張機能のアイコンが表示されるのですが、このアイコンをクリックするとアクティブなタブのキャプチャが可能な限り速やかに保存されます。

右クリックして選択可能な「オプション」にて拡張機能の設定が可能で、キャプチャ範囲の選択肢として、

  • サイト全体をほぼ完璧にキャプチャする全て(高品質)モード
  • 低画質な代わりにサイト全体を一瞬でキャプチャする全て(高速)モード
  • 現在表示している範囲のみキャプチャする表示中モード

が選択可能です。

更に、ダウンロードされる png ファイルの名前も設定可能で、任意の文字列以外に

  • {{title}}: ページタイトルに変換
  • {{url}}: URLに変換
  • {{counter}}: 連番に変換

という自動で展開される変数も用意してあります。

ボタンが押されてから画像をダウンロードするまでの流れ

本題に入りましょう。
アプリでは拡張機能のアイコンを押す or 右クリックでメニューを呼び出すときにキャプチャが開始されますが、その間に発生する処理の流れは以下の通りです。

  1. 必要な設定を読み込む
  2. 現在表示しているサイトの表示サイズ・全体サイズを算出する
  3. 必要な回数、画面をスクロールしながらキャプチャを行い、画像データを取り溜める
  4. ダウンロードする画像と同じサイズの canvas 要素を生成し、画像を並べる
  5. canvas の toDataURL() を用い、画像を吐き出す
  6. 設定に基づきファイル名を決定、ダウンロードする

何気なく書いてますが、API を多用する関係上幾度となく非同期処理が挟まるため、コードは実質 Promise 祭りです。

また、この流れ以外にも拡張機能の「オプション」を設定・保存するための処理も必要です。

そもそも、今回の拡張機能を作成するにはどのような設計が必要なのか

設計と言葉を濁しましたが、まず最初に Chrome の拡張にはどんなことをする「場」があって、その各「場」に対応するファイルはなんなのかという説明をしたいと思います。

まず、Chrome に対する指示書たる manifest.json について理解しましょう。

詳しくは以下の記事に解説が載っています。僕もこれを見ながら開発しました。

Chrome 拡張機能のマニフェストファイルの書き方

詳細は上記で見てもらうとして、ここで取り上げたい項目が三つあります。

manifest.json
{
    "background": {
        "persistent": false,
        "scripts": ["background.bundle.js"]
    },
    "content_scripts": [{
        "js": ["page.bundle.js"],
        "matches": ["http://*/*", "https://*/*"]
    }],
    "options_ui": {
        "page": "options.html",
        "chrome_style": true
    },
}

上記は実際に作った拡張機能の抜粋になりますが、この三つの項目が先述の「場」に対応する設定となります。

そもそも、拡張機能は JavaScript を動かすことで実行することになるのですが、その JavaScript を動かせる場として

  • バックグラウンド
  • 閲覧中の Web サイト
  • 拡張機能の設定画面

の三種類があり、それぞれが上記コードの

  • background
  • content_scripts
  • options_ui

に対応しています。

それぞれできることが違うのですが、今回の拡張機能では

  • バックグラウンド → 実行のトリガー・キャプチャの実行・ダウンロード
  • 閲覧中の Web サイト → css・スクロール制御
  • 拡張機能の設定画面 → 設定の表示・更新

を行うため、各種 JavaScript を用意します。

設定を保存するための画面を用意する

文言を国際化する

設定画面は自分でデザインすることもできますが、css を用意しなくても Chrome が簡易なスタイルを用意してくれているのでそちらを利用することにします。

manifest.json
{
  ~略~
  "options_ui": {
    "page": "options.html",
    "chrome_style": true
  },
  ~略~
}

上記 json が示している通り、拡張機能のオプション画面が options.html となります。

普通の Web サイトを作成するように作成して問題ないのですが、ここでひと手間掛けたいのが文言の国際化です。

この拡張機能のオプション設定画面は日本語環境では日本語、それ以外では英語で文言を表示するようにしています。

拡張機能の国際化には chrome.i18n という API を使用しますが、残念ながらリンク先は英語一本です。エンジニアたるもの言語に屈してはならないのです

屈した三流園児ニアの僕は以下の記事と Google 翻訳を駆使して開発に臨みました。
Google さんに怒りをぶつけようとしても Google 翻訳というありがたいサービスの前にはただただひれ伏すのみです。
Chromeエクステンションを作ろう:国際化編

_locals/ja/message.json
{
  "msg_desc": {"message": "Immediate Shot はワンクリックで素早くスクリーンショットを撮る Chrome 用拡張機能です。"},
  "msg_saved": {"message": " 設定を保存しました。"},
  "msg_option_title_captureRange": {"message": "範囲"},
  "msg_option_description_full": {"message": "全て(高速)"},
  "msg_option_description_display": {"message": "表示中"},
  "msg_option_description_perfect": {"message": "全て(高品質)"},
  "msg_option_title_fileName": {"message": "ファイル名"},
  "msg_option_title_templates": {"message": "ファイル名変数"},
  "msg_option_description_title": {"message": "ページタイトルに置き換わります"},
  "msg_option_description_url": {"message": "URLに置き換わります"},
  "msg_option_description_counter": {"message": "下記'count'に置き換わります(数値は一つずつ増えていきます)"},
  "msg_option_title_counter": {"message": "カウンター(ファイル名にて{{counter}}として使用)"},
  "msg_option_max": {"message": "チェックするとWebサイトの最大幅を全要素から検索するようになります(全画面スクリーンショットが上手くいかない場合にお試しください)"},
  "msg_option_save": {"message": "保存"}
}
options.html
<!--略-->
  <li>
    <spna><span class="lang" data-key="option_title_captureRange"></span>: </spna>
    <label><input id="perfect" name="range" type="radio" value="perfect"><span class="lang" data-key="option_description_perfect"></span></label>
    <label><input id="full" name="range" type="radio" value="full"><span class="lang" data-key="option_description_full"></span>&nbsp;</label>
    <label><input id="display" name="range" type="radio" value="display"><span class="lang" data-key="option_description_display"></span>&nbsp;</label>
  </li>
<!--略-->
options.ts
  //~略~

  /**
   * lang クラスを持った要素の 'msg_' + data-key属性 から言語メッセージを取得し、要素のテキストを変更する
   */
  const setLang = () => {
    //テキスト変換対象の取得
    const targets = document.getElementsByClassName('lang');

    //変換処理
    for (let i = 0, max = targets.length; i < max; i = (i + 1) | 0) {
      //対象を一旦変数へ挿入
      const target = targets.item(i);

      //対象が存在しなかったらなにもしない
      if (target === null) {
        continue;
      }

      //メッセージキーを一旦変数へ挿入
      const key = target.getAttribute('data-key');

      //メッセージキーが存在しなかったらなにもしない
      if (key === null) {
        continue;
      }

      //テキスト設定
      target.innerHTML = chrome.i18n.getMessage('msg_'+key);
    }
  };

  //~略~

  //言語ごとにテキスト設定
  document.addEventListener('DOMContentLoaded', setLang);

上記のように、_locals/iso 639 言語コード/message.json を用意しておくと chrome.i18n.getMessage で自動判別された言語のメッセージを取得できるので、この拡張機能では class 属性に lang を持つ要素の data-key 属性で翻訳を行うというアプローチをとっています。

設定の取り出し・保存

設定画面のスクリーンショット

設定値は chrome.storage API を通じて取得・更新します。

まずは取得ですが、まだ値を保存していないキーに対しての初期値が必要です。
保存は設定画面でしか行いませんが、取得は拡張機能の至る所で行うので、設定だけ保存しておく TypeScript ファイルを用意しておきます。

config.ts
//キャプチャ範囲初期値
export const DEFAULT_RANGE: string = 'perfect';

//タイトル名初期値
export const DEFAULT_TITLE: string = '{{title}}';

//サイトのマックス値を画面幅だけで取るか、全要素から取得するか
export const DEFAULT_MAX: boolean = false;

//カウント変数初期値
export const DEFAULT_COUNTER: number = 1;

/*----以下二つは関係なし----*/

//複数枚キャプチャの際、次のキャプチャまで何ミリ秒間隔を置くか
export const CAPTURE_WAIT_MILLISECONDS: number = 20;

//CAPTURE_WAIT_MILLISECONDS が使われる際、最初の一回だけはこの値が使用される
export const FIRST_CAPTURE_WAIT_MILLISECONDS: number = 100;

実際に使用する際には、

options.ts
import {DEFAULT_COUNTER, DEFAULT_MAX, DEFAULT_RANGE, DEFAULT_TITLE} from "./config";

  //~略~

  /**
   * 読み取り
   */
  const restore_options = () => {
    chrome.storage.sync.get({
      range: DEFAULT_RANGE,
      title: DEFAULT_TITLE,
      counter: DEFAULT_COUNTER,
      max: DEFAULT_MAX
    }, (items: {[key: string]: string}) => {
      setCheckedRadio(items.range);
      setValue('title', items.title);
      setValue('counter', String(items.counter));
      setCheckedCheckbox('max', Boolean(items.max));
    });

  //~略~

  document.addEventListener('DOMContentLoaded', restore_options);

  //~略~

のように、chrome.storage.sync.get() の第二引数がコールバックになっているので、ここでは item 引数に入ってきた設定値を各要素に描写する関数へ渡しています。

保存する際は逆に各要素から value を読み取り、 chrome.storage.sync.set() で保存を行います。

options.ts
import {DEFAULT_COUNTER, DEFAULT_MAX, DEFAULT_RANGE, DEFAULT_TITLE} from "./config";

  //~略~

  /**
   * 保存
   */
  const save_options = () => {
    //設定の取得(range)
    let range = 'full';
    if (isCheckedRadio('display')) {
      range = 'display';
    }
    else if (isCheckedRadio('perfect')) {
      range = 'perfect';
    }

    //設定の取得(title)
    const title = getValue('title');

    //設定の取得(counter)
    const counter = Number(getValue('counter'));

    //設定の取得(max)
    const max = isCheckedCheckbox('max');

    //保存
    chrome.storage.sync.set({range, title, counter, max}, () => {
      const status = document.getElementById('status');
      if (status === null) {
        return;
      }
      status.textContent = chrome.i18n.getMessage('msg_saved');
      setTimeout(() => {
        status.textContent = '';
      }, 750);
    });
  };

  //~略~

  const save = document.getElementById('save');
  if (save !== null) {
    save.addEventListener('click', save_options);
  }

  //~略~

chrome.storage.sync.set() にも第二引数にコールバックが仕込めるので設定の保存が完了した表示を出すのに利用しています。

スクリーンキャプチャを撮るための前提

仮に、現在写っている画面のみを撮るのであれば

この拡張機能の肝となるキャプチャは「現在写っている部分のみ」ならば以下のようなコードで実現可能です。

manifest.json
{
  ~略~
  "permissions": ["tabs", "downloads"],
  ~略~
}
background.ts
const action = () => {
  chrome.tabs.captureVisibleTab((url) => {
    chrome.downloads.download({url: url, filename: 'capture.png');
  });
};

tabs を許可すると画面キャプチャを base64 データとして取得できる chrome.tabs.captureVisibleTab が、downloads を許可すると直接ファイルをダウンロードできる chrome.downloads.download が使用できます。

後々重要になってくる制約ですが、chrome.tabs.captureVisibleTab は先述したバックグラウンドでのみ使用できる API です。

アドレスバー横のアイコンをクリックした際なら、

background.ts
chrome.browserAction.onClicked.addListener(action);

右クリックメニューに項目を用意するのであれば、

manifest.json
{
  ~略~
  "permissions": ["contextMenus"],
  ~略~
}
background.ts
//メニューの登録
chrome.contextMenus.create({
  id: 'run',
  title: 'Immediate Shot',
  contexts: ['all'],
  type: 'normal'
});

//クリックされたとき
chrome.contextMenus.onClicked.addListener(action);

のようにバックグラウンド用の JavaScript を用意すれば目的は達成できるでしょう。
これらトリガー設定もバックグラウンドのみが行える仕事です。

全面キャプチャを撮るためには画面をスクロールしながらキャプチャを断片的に撮り溜める必要がある

しかし、残念ながら chrome.tabs.captureVisibleTab に対してサイトの全面キャプチャを撮る chrome.tabs.captureFullVisibleTab 的な API は今のところ実装されていません。

それなのに世のキャプチャ系拡張機能はどうやって全面キャプチャを撮っているのかというと、

  1. サイトの一番左上までスクロールする
  2. 現在表示されている範囲をキャプチャする
  3. 表示されていない範囲を撮るために右 or 下へスクロール
  4. 横スクロール&縦スクロールできる範囲を撮りつくすまで 2 → 3 を繰り返す
  5. 取り溜めた各画像を HTML Canvas 上に再度並べ直し、合成して一枚の画像データとして吐き出す
  6. データをダウンロード

という涙ぐましい努力を行うことで全面キャプチャを実現しています。

ロジックを組むだけで眩暈がしそうですが、真に骨が折れるのは上記 2 と 3 の間が非同期処理でやり取りされるという点です。

  • background
  • content_scripts
  • options_ui

この中で唯一出番のなかった content_scripts はここで出てきます。

先述したように、キャプチャを撮る、ボタンクリックをトリガーする等の仕事は background でしかできません。

対して閲覧中の Web サイトに直接働きかける、つまり

  • 要素の情報を取得する(座標やボックスモデル)
  • 要素のスタイルを変更する
  • 画面をスクロールする

等の処理は content_scripts のみが行える仕事です。

つまり、上記フローには両者の相互通信が必要になり、具体的には、

  1. content_scripts が左上へスクロールを行う
  2. background がキャプチャを行う
  3. background から content_script へ次の座標へスクロールの依頼をする
  4. content_scripts はスクロールが完了次第 background へ応答する
  5. 応答を受け、background は再度キャプチャを行う

というやり取りを行うこととなります。
ちょうどバックエンドとフロントエンドの Ajax 通信と似てるので、同じノリで開発することにしましょう。

transform: scale() を使用して一発で全面キャプチャを撮る(低解像度)

ここまで説明しておいてなんですが、画像が荒くてもいいのであれば一発で全面キャプチャを撮る方法があります。
css の transform: scale() を使用して、Web サイト全面を現在の表示領域に収まるまで縮小してキャプチャすればいいのです。

Sizing.ts
import {FindStyle} from "./FindStyle";

export class Sizing {

  //~略~

  /**
   * style タグを挿入する
   * 既にこのクラスが扱っている style が存在した場合はリセットする
   * @param style
   * @private
   */
  private _appendStyle(style: string) {
    //リセット
    this._removeStyle();

    //style タグを用意
    const tag = document.createElement('style');
    tag.setAttribute('id', this.STYLE_ID);
    tag.innerText = style;

    //tag タグ挿入
    document.head.appendChild(tag);
  }

  //~略~

  /**
   * 各種情報をアップデートする
   * @private
   */
  private _updateInformation(max: boolean) {
    //全要素サイズ取得用インスタンス
    let findStyle = new FindStyle(document.getElementsByTagName('html')[0]);

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //幅と高さそれぞれの割合
    const widthRatio = this.windowWidth / this.documentWidth;
    const heightRatio = this.windowHeight / this.documentHeight;

    //ratio と ratioType のセット
    this.ratio = widthRatio > heightRatio ? heightRatio : widthRatio;
    this.ratioType = widthRatio > heightRatio ? 'height' : 'width';

    //ratio が 1 以上だったら 1 とする
    this.ratio = this.ratio > 1 ? 1 : this.ratio;

    //~略~
  }

  /**
   * フルサイズ用のサイジング処理を行う
   */
  public fullSizing(): Coordinates {
    //style タグを生成
    this._appendStyle('body{overflow:hidden;transform-origin: left top;transform: scale('+this.ratio+')}');

    //スクロール位置を 0 にする
    window.scrollTo(0, 0);

    //0, 0 を返す
    return {
      x: 0,
      y: 0
    };
  }

  //~略~
}

上記はスクロールなどの処理を行うクラスの一部ですが、ここでは現在のウィンドウサイズとドキュメントサイズを比較して body の大きさを現在のウィンドウに収まる大きさにしています。
こうしてしまえば画像はかなり小さくなってしまうもののキャプチャが一発で済み非常に高速なので、実際の拡張機能ではモードの一つとして採用しています。

完全な全面キャプチャの撮影処理

現在の Web ページ情報と設定を取得する

キャプチャのために必要な情報は以下の三種類です。

  • 現在開いているタブ
  • そのタブで表示されているページ情報
  • 拡張機能の設定

これら情報を一気に取得するための関数を作成しましょう。

background.ts
import {Information, Settings, Range} from "src/class/interface";
import {Capturing} from "./class/Capturing";
import {Filename} from "./class/Filename";
import './config';
import {CAPTURE_WAIT_MILLISECONDS, DEFAULT_COUNTER, DEFAULT_RANGE, DEFAULT_MAX, DEFAULT_TITLE, FIRST_CAPTURE_WAIT_MILLISECONDS} from "./config";

interface InitData {
  tab: chrome.tabs.Tab,
    settings: Settings,
      information: Information
}

{

  /**
   * 拡張機能の設定と現在参照中のタブ情報を返す
   */
  const init = () => {
    /**
     * range を Range 型にキャストする
     * @param range
     */
    const castRange = (range: string): Range => {
      switch (range) {
        case 'full':
        case 'display':
        case 'perfect':
          return range;
          break;
        default:
          return 'full';
          break;
      }
    };

    return new Promise<InitData>(resolve => {
      //現在開いているタブを入手
      new Promise<chrome.tabs.Tab>(innerResolve => {
        chrome.tabs.query({active: true}, (tabs: chrome.tabs.Tab[]) => {
          innerResolve(tabs[0]);
        });
      })
        .then(tab => {
        //拡張機能の設定を入手
        return new Promise<{tab: chrome.tabs.Tab, settings: Settings}>(innerResolve => {
          chrome.storage.sync.get({range: DEFAULT_RANGE, title: DEFAULT_TITLE, counter: DEFAULT_COUNTER, max: DEFAULT_MAX}, (items: {[key: string]: string}) => {
            innerResolve({tab, settings: {range: castRange(items.range), title: String(items.title), counter: Number(items.counter), max: Boolean(items.max)}});
          });
        });
      })
        .then((data: {tab: chrome.tabs.Tab, settings: Settings}) => {
        //現合表示しているタブの情報を入手
        chrome.tabs.sendMessage(Number(data.tab.id), {type: 'information', max: data.settings.max}, (information: Information) => {
          resolve({tab: data.tab, settings: data.settings, information});
        });
      });
    });
  };

  //~略~

}

Promise だらけですが、要は各 Promise で上記情報を取得しています。

Google が提供する API は先ほども登場した chrome.storage.sync.get() のようにコールバックを引数に仕込むことで非同期処理を実現するものが殆どです。

しかし API を複数実行する場合は典型的なコールバック地獄になるため、各 API を自前の Promise でラップしてあげるのが基本方針となります。

最終的に init() 自体もそれら全てをラップした Promise を返すようにすることでこれら情報をひとまとめに受け取るということですね。

さて、ここで注目したいのが実際に表示しているページの情報を取得している、

background.ts
chrome.tabs.sendMessage(Number(data.tab.id), {type: 'information', max: data.settings.max}, (information: Information) => {
  resolve({tab: data.tab, settings: data.settings, information});
});

の部分です。

chrome.tabs.sendMessage() は第一引数で指定したタブで動いている content_scripts に対して第二引数のオブジェクトを渡す API です。

第三引数は content_scripts から返ってきたオブジェクトを受け取るコールバックとなっています。

さっそく今回の拡張機能における受け取り側を見てみましょう。

page.ts
import {Range, Coordinates} from "./class/interface";
import {Sizing} from "./class/Sizing";
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //ここに関数や変数を定義する

  //~略~

  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 受け取った値で分岐
    switch (request.type) {
      case 'information':
        sendResponse(information(request.max));
        break;
      case 'sizing':
        sendResponse(styling(request.range, request.index, request.max));
        break;
      case 'killFixed':
        controlFixed('hidden');
        sendResponse({});
        break;
      case 'resetSizing':
        controlFixed('');
        resetSizing({x: request.x, y: request.y});
        sendResponse({});
        break;
      default:
        sendResponse({});
        break;
    }
  });


  //~略~

  //ここに関数や変数を定義する

  //~略~

});


バックグラウンドページと違い、 content_scripts として登録された .js ファイルは通常の Web サイトに <script> タグで埋め込んだ JavaScript ファイルと同じようにふるまいます。

しかし特異かつ肝なのは下記の部分です。

page.ts
  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {}

先ほどの chrome.tabs.sendMessage がバックグラウンドから呼ばれると、この chrome.runtime.onMessage イベントに登録された関数が実行されます。

このイベントに登録された関数は三つの引数を受け取りますが、今回用があるのはバックグラウンドで chrome.tabs.sendMessage の第二引数に仕込んだ情報である第一引数(request)と、レスポンスを返すときに使う関数である第三引数(sendResponse)です。

chrome.runtime - Google Chrome (onMessage イベント)

上記にもある通り、request の中に type として仕込んだ string で実際に起動する関数を選択し、その返り値を sendResponse() でバックグランドページに返してあげれば複数の要件で二つのスクリプト間のメッセージングが可能となるわけです。

今回バックグラウンドから呼ばれた要求は type :information なので、page.tsinformation() を見てみましょう。

page.ts
  //サイズを取得するためのクラス
  const sizing = new Sizing();

  //~略~

  //表示されているタブの情報を返す
  const information = (max: boolean) => {
    return sizing.getInformation(max);
  };

Sizing.ts
import {Coordinates, Information} from "src/class/interface";
import {FindStyle} from "./FindStyle";

export class Sizing {

  //constructor() 時点の window width
  private windowWidth: number = 0;

  //constructor() 時点の window height
  private windowHeight: number = 0;

  //constructor() 時点の document width
  private documentWidth: number = 0;

  //constructor() 時点の document height
  private documentHeight: number = 0;

  //画面縮小比率
  private ratio: number = 0;

  //画面を幅と高さのどちらで縮小したか
  private ratioType: 'width' | 'height' = 'height';

  //documentWidth を現在の windowWidth の大きさでキャプチャするには横に何枚キャプチャが必要か
  private widthCaptureNumber: number = 0;

  //documentHeight を現在の windowHeight の大きさでキャプチャするには縦に何枚キャプチャが必要か
  private heightCaptureNumber: number = 0;

  //上記二つの乗算値
  private captureNumber: number = 0;

  //constructor() 時点のスクロール位置(横)
  private scrollX: number = 0;

  //constructor() 時点のスクロール位置(縦)
  private scrollY: number = 0;

  /**
   * 各種情報をアップデートする
   * @private
   */
  private _updateInformation(max: boolean) {
    //全要素サイズ取得用インスタンス
    let findStyle = new FindStyle(document.getElementsByTagName('html')[0]);

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //幅と高さそれぞれの割合
    const widthRatio = this.windowWidth / this.documentWidth;
    const heightRatio = this.windowHeight / this.documentHeight;

    //ratio と ratioType のセット
    this.ratio = widthRatio > heightRatio ? heightRatio : widthRatio;
    this.ratioType = widthRatio > heightRatio ? 'height' : 'width';

    //ratio が 1 以上だったら 1 とする
    this.ratio = this.ratio > 1 ? 1 : this.ratio;

    //縦と横においてそれぞれ現在のウィンドウサイズ何枚分で全画面を捕捉できるかの数値を算出
    this.widthCaptureNumber = Math.ceil(this.documentWidth / this.windowWidth);
    this.heightCaptureNumber = Math.ceil(this.documentHeight / this.windowHeight);

    //上記二つの乗算値
    this.captureNumber = this.widthCaptureNumber * this.heightCaptureNumber;

    //現在のスクロール座標を記録
    this.scrollX = window.scrollX;
    this.scrollY = window.scrollY;
  }

  /**
   * 情報を返す
   * @return {{documentWidth: number | *, documentHeight: number | *, windowHeight: number | *, ratioType: string, windowWidth: number | *, ratio: (*|number)}}
   */
  public getInformation(max: boolean = false): Information {
    //情報の更新
    this._updateInformation(max);

    //計算結果を返す
    return {
      windowWidth: this.windowWidth,
      windowHeight: this.windowHeight,
      documentWidth: this.documentWidth,
      documentHeight: this.documentHeight,
      widthCaptureNumber: this.widthCaptureNumber,
      heightCaptureNumber: this.heightCaptureNumber,
      captureNumber: this.captureNumber,
      ratio: this.ratio,
      ratioType: this.ratioType,
      scrollX: this.scrollX,
      scrollY: this.scrollY

  }

//~略~

}

怯んではなりません。後々これだけの情報が必要なのです。

ただし、コードを書いている身としてもこれら情報セットは複雑なのでバグ防止・コード補完のために TypeScript の interfaceInformation を作成し、各コードで使いまわしています。

interface.ts
//~略~
export interface Information {
  windowWidth: number,
  windowHeight: number,
  documentWidth: number,
  documentHeight: number,
  widthCaptureNumber: number,
  heightCaptureNumber: number,
  captureNumber: number,
  ratio: number,
  ratioType: string,
  scrollX: number,
  scrollY: number
}
//~略~

必要なキャプチャ枚数とスクロール座標を算出する

init() で必要な情報は受け取ったので、今度は必要なキャプチャ枚数と、それぞれのスクロール座標を算出しましょう。

大体のサイトは縦長で縦スクロールしか存在しませんが、当然ほとんどの Web ブラウザは二次元の表示領域を持つので横スクロールのことを無視するわけにはいきません。

縦スクロールと横スクロールそれぞれを勘案に入れて、現在表示している Web ページは何枚のキャプチャが必要でしょう?

板チョコを想像してみてください。

板チョコ.png

板チョコ1ピース分が 現在のウィンドウサイズ、つまり見えている部分の大きさだとしましょう。

上の図の二つ目を例にとると、左上に重ねた板チョコ1枚分では Web サイトの横幅全てをカバーすることはできませんが、2枚あれば横幅をカバーすることができそうです。
同様に、縦幅は5枚あればカバーできそうなので、板チョコは計10枚必要であることが分かります。

先ほどは説明を飛ばしましたが、 Information に定義されている captureNumber はまさにこの総枚数に当たり、同様に widthCaptureNumberheightCaptureNumber はそれぞれ行数と列数になります。

Sizing.ts
  private _updateInformation(max: boolean) {
    //~略~

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //~略~

    //縦と横においてそれぞれ現在のウィンドウサイズ何枚分で全画面を捕捉できるかの数値を算出
    this.widthCaptureNumber = Math.ceil(this.documentWidth / this.windowWidth);
    this.heightCaptureNumber = Math.ceil(this.documentHeight / this.windowHeight);

    //上記二つの乗算値
    this.captureNumber = this.widthCaptureNumber * this.heightCaptureNumber;

    //~略~
  }

windowWidthwindowHeight がそれぞれ板チョコ1ピース分の widht, height となるので、これら情報でキャプチャ枚数と、それぞれのスクロール座標情報が算出することが可能になりました。

実際にスクロールさせる各板チョコピースの左上座標を算出するには以下の関数を通すと算出できます。

Sizing.ts
  /**
   * 指定された index からスクロールすべき座標を返す
   * @param index
   * @private
   */
  private _getScrollCoordinates(index: number): Coordinates {
    return {
      x: Math.floor(index % this.widthCaptureNumber) % this.captureNumber * this.windowWidth,
      y: Math.floor(index / this.widthCaptureNumber) % this.captureNumber * this.windowHeight
    };
  }

index には処理するピースのインデックス番号を入れるのですが、上記画像の計10枚を例にとるなら、

0 1
2 3
4 5
6 7
8 9

という位置のスクロール座標を返します。

4列3行なら、

0 1 2 3
4 5 6 7
8 9 10 11

といった具合となります。

通常のスクロールではなく transition: translate で疑似的なスクロールを行いながらキャプチャを行う

キャプチャのための情報が出そろったところで素直に window.scrollTo(x, y) 等でスクロールを行ってみると右端・下端でキャプチャが上手くいきません。

当然ながら画面のスクロールは Web サイトの最大幅を超過して行うことはできないからです。

先ほどの板チョコ図を見てみましょう。

板チョコ (1).png

理想としては左図のようにキャプチャしたいところですが、実際にスクロールした場合座標が指定した位置よりも手前で止まってしまい、右端・下端のキャプチャは右図のようにすぐ隣の領域と被ってしまうのです。

また地味に困るのが、Web サイトによってはスクロールによってサイトのデザインが変化する場合があることです。
特に顕著なのがスクロールに応じて固定されたヘッダーが出たり引っ込んだりするサイトです。このようなサイトはいざ下の方のキャプチャを行おうとした際に余計なヘッダーが出現し、後で画像を合成したときに空中に浮いているヘッダーが描画されてしまい、とても見づらいキャプチャ画像となってしまいます。

これらに対処するため、実際にキャプチャ範囲を移動する際は body タグに対して tranform: translate(-x, -y); を指定して疑似的にスクロールを行います。

x座標、y座標共に window.scrollTo(x, y) で指定する値に -1 を掛けた数値を使用しないと逆方向に移動してしまうのに注意しましょう。

translate() - CSS: カスケーディングスタイルシート | MDN

ついでにスクロールバーも画像合成の場合に邪魔になるので overflow: hidden を同じく body タグに指定します。

Sizing.ts
  /**
   * このクラスが仕込んだ style タグを削除する
   * @private
   */
  private _removeStyle() {
    //削除対象の取得
    const target = document.getElementById(this.STYLE_ID);

    //target が存在しなかったら何もしない
    if (target === null) {
      return;
    }

    //対象を削除する
    target.remove();
  }

  /**
   * style タグを挿入する
   * 既にこのクラスが扱っている style が存在した場合はリセットする
   * @param style
   * @private
   */
  private _appendStyle(style: string) {
    //リセット
    this._removeStyle();

    //style タグを用意
    const tag = document.createElement('style');
    tag.setAttribute('id', this.STYLE_ID);
    tag.innerText = style;

    //tag タグ挿入
    document.head.appendChild(tag);
  }

  /**
   * 指定された index からスクロールすべき座標を返す
   * @param index
   * @private
   */
  private _getScrollCoordinates(index: number): Coordinates {
    return {
      x: Math.floor(index % this.widthCaptureNumber) % this.captureNumber * this.windowWidth,
      y: Math.floor(index / this.widthCaptureNumber) % this.captureNumber * this.windowHeight
    };
  }

  //~略~

  /**
   * スクロールバーを消すだけのサイジング処理を行う
   * スクロール位置は index 番号で指定する
   * index が null だった場合はスクロールを変更しない
   * この index 番号は getInformation() で取得できる captureNumber の範囲で指定し、
   * 例えば
   * widthCaptureNumber = 4
   * heightCaptureNumber = 3
   * captureNumber = 12
   * だった場合は
   * +----+----+----+----+
   * |  0  |  1  |  2  |  3  |
   * +----+----+----+----+
   * |  4  |  5  |  6  |  7  |
   * +----+----+----+----+
   * |  8  |  9  | 10 | 11 |
   * +----+----+----+----+
   * といった各マスの左上座標へスクロールすることになる
   * 各マスの width, height = windowWidth, windowHeight
   * 大枠の width, height = documentWidth, documentHeight
   */
  public displaySizing(index: number|null = null, max: boolean = false): Coordinates {
    //index 指定が無かったら style タグを適用の後、(0, 0)を返す
    if (index === null) {
      //style タグを生成
      this._appendStyle('body{overflow:hidden}');

      //(0, 0)返す
      return {
        x: 0,
        y: 0
      };
    }

    //もし index が 0 だったらスクロール位置を 0, 0 にする
    if (index === 0) {
      document.getElementsByTagName('html')[0].scrollTo(0, 0);
    }

    //移動先座標の定義
    const coordinates = this._getScrollCoordinates(index);

    //overflow スタイルの適用 & transform: translate による疑似的なスクロールの実行
    this._appendStyle(max
      ? 'body{overflow:hidden;transform:translate('+(coordinates.x * -1)+'px,'+(coordinates.y * -1)+'px);width: '+this.documentWidth+'px;height: '+this.documentHeight+'px;}'
      : 'body{overflow:hidden;transform:translate('+(coordinates.x * -1)+'px,'+(coordinates.y * -1)+'px)}'
    );

    //スクロール情報を返す
    return coordinates;
  }

この Sizing.displaySizing() を手に入れたキャプチャ定義ごとに呼べば望み通りの位置に画面が移動します。

page.ts
import {Range, Coordinates} from "./class/interface";
import {Sizing} from "./class/Sizing";
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //ブラウザの大きさを適切なものに変える
  const styling = (range: Range, index: number, max: boolean) => {
    //処理終了後の座標情報
    let coordinate: Coordinates = {
      x: 0,
      y: 0
    };

    //range によって処理を分ける
    switch (range) {
      case 'full':
        coordinate = sizing.fullSizing();
        break;
      case 'perfect':
        coordinate = sizing.displaySizing(index, max);
        break;
      default:
        coordinate = sizing.displaySizing(null);
        break;
    }

    //座標情報を返す
    return coordinate;
  };

  //~略~

  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 受け取った値で分岐
    //~略~
      case 'sizing':
        sendResponse(styling(request.range, request.index, request.max));
        break;
    //~略~
  });

  //~略~

});

background.ts
import {Information, Settings, Range} from "src/class/interface";
import {Capturing} from "./class/Capturing";
import './config';
import {CAPTURE_WAIT_MILLISECONDS, DEFAULT_COUNTER, DEFAULT_RANGE, DEFAULT_MAX, DEFAULT_TITLE, FIRST_CAPTURE_WAIT_MILLISECONDS} from "./config";

{
  //Capturing クラス
  const capturing = new Capturing();

  //~略~

  /**
     * 現在表示しているタブのキャプチャを一回行う
     * @param id
     * @param range
     */
  const createCapture = (id: number, range: Range, index: number, max: boolean = false): Promise<void> => {
    return  new Promise(resolve => {
      //index === 1 (キャプチャが二回目)の場合は position: fixed の要素を非表示にする
      if (index === 1) {
        chrome.tabs.sendMessage(id, {type: 'killFixed'});
      }

      //スクロール・キャプチャ
      chrome.tabs.sendMessage(id, {type: 'sizing', range: range, index: index, max: max}, response => {
        setTimeout(() => {
          capturing.capture(response.x, response.y)
            .then(() => {
            resolve();
          });
        }, index < 2 ? FIRST_CAPTURE_WAIT_MILLISECONDS : CAPTURE_WAIT_MILLISECONDS);
      });
    });
  };

  /**
     * settings と information から求められている画像サイズを導き出す
     * @param settings
     * @param information
     */
  const getImageSize = (settings: Settings, information: Information): {width: number, height: number} => {
    //最終的な画像サイズを決定(この時点では range = display 用)
    let width = information.windowWidth;
    let height = information.windowHeight;

    //range に合わせた画像サイズを用意
    switch (settings.range) {
      case 'full':
        width = information.ratioType === 'width'
          ? information.windowWidth
        : information.windowWidth * information.ratio;
        height = information.ratioType === 'height'
          ? information.windowHeight
        : information.windowHeight * information.ratio;
        break;
      case 'perfect':
        width = information.documentWidth;
        height = information.documentHeight;
        break;
    }

    //返す
    return  {width, height};
  };

  /**
     * 現在開いているタブのキャプチャを行う
     * @param settings
     * @param information
     * @param tab
     */
  const getDataURL = async (settings: Settings, information: Information, tab: chrome.tabs.Tab) => {
    //何枚の画像をキャプチャするか
    const captureNumber = settings.range === 'perfect'
    ? information.captureNumber
    : 1;

    //サイズ取得
    const size = getImageSize(settings, information);

    //キャプチャ処理を必要な回数だけ行う
    for (let i = 0, max = captureNumber; i < max; i = (i + 1) | 0) {
      await createCapture(Number(tab.id), settings.range, i, settings.max);
    }

    //スタイルを元に戻す
    chrome.tabs.sendMessage(Number(tab.id), {type: 'resetSizing', x: information.scrollX, y: information.scrollY});

    //dataURL 化
    return capturing.compose(size.width, size.height);
  };

  //~略~

}

Caputuring クラスは後で説明しますが、 capture() でキャプチャした画像データをクラス内に溜めておき、compose で合成した画像データを返す役割を担っています。

このキャプチャを行う処理は直列の非同期処理ですが、画面条件によって何枚のキャプチャが必要になるかは動的に決定されるため、then() による Promise チェーンを繋ぐコードが静的に書けません。

このように動的な数の非同期処理を直列実行する場合は Array.reduce() を駆使するのが定番ですが、今回は全ブラウザに対応する必要が無い拡張機能の制作ということで async/await を使用してもう少し分かりやすく実装することにしました。

getDataURL()async がつけてあるので内部で await が使用可能になり、

background.ts
for (let i = 0, max = captureNumber; i < max; i = (i + 1) | 0) {
  await createCapture(Number(tab.id), settings.range, i, settings.max);
}

の部分は前の createCapture() が終了(= Promise.resolve() が返るまで)するまで次の createCapture() の実行が待たれるようになります。

スクロールバーと position: fixed を非表示にする

先ほども軽く触れましたが、スクロールに関する問題を解決してもやはり固定された要素は厄介です。

サイト上部へ常に固定されているヘッダーをはじめ、サイト右下に居座る TOP へ戻るボタンや、妙なタイミングで出てくるモーダルウィンドウ等、こういった要素は 宇宙に存在する全ての position: fixed を許さない過激派 のみならず、サイトのキャプチャを撮ろうとする我々開発者をも憎悪の渦に叩きこんできました。

これら要素のうち大抵のものは先述の疑似スクロールによって固定されずに済むものの、やはり2枚目以降のキャプチャ取得時には非表示にしたいものです。

なので、この拡張機能では愚直に全要素を取得し、postion: fixed が適用されている要素のみ一時的に visibility: hidden で非表示にしています。

実装前はかなり重たい処理になるかと思われましたが、実際に作ってみると大抵のサイトで 0.1 秒もかからず全ての要素を非表示にすることができたので、以下のコードを採用しました。
自分で言うのもなんですが、これだけで別の拡張機能が作れそうですね。

FindStyle.ts
export class FindStyle {

  /**
   * constructor で挿入する HTMLElement
   * この要素にぶら下がっている DOM ツリーが対象
   */
  readonly root: HTMLElement;

  /**
   * root 下の全 HTMLElement
   * 階層楮は無く、一次元配列として捕捉
   */
  private elements: HTMLElement[];

  /**
   * target が null でないことを保証する Type guard
   * HTMLElement.children から取ってきたオブジェクトに対して用いる
   * @param target
   * @private
   */
  private _isHTMLElement(target: any): target is HTMLElement {
    return  target !== null;
  }

  /**
   * parent 下にぶら下がる DOM ツリーを再帰的に取得し、this.elements に追加する
   * @param parent
   * @private
   */
  private _findChildren(parent: HTMLElement) {
    //自身をpush
    this.elements.push(parent);

    //子要素の取得
    const children = parent.children;

    for (let i = 0, max = children.length; i < max; i = (i + 1) | 0) {
      //タイプガードを通すため、一旦変数へ格納
      const target = children.item(i);

      //target が null でないことを保証
      if ( ! this._isHTMLElement(target)) {
        continue;
      }

      //再帰的にこの関数を呼ぶ
      this._findChildren(target);
    }
  }

  /**
   * ドキュメントルートを確保し、検索対象の要素を捕捉する
   * @param root
   */
  constructor(root: HTMLElement) {
    //検索対象ツリーの親要素を登録
    this.root = root;

    //検索結果配列を初期化
    this.elements = new Array();

    //検索開始
    this._findChildren(root);
  }

  /**
   * css として property: value が適用されている要素を this.elements から取得する
   * @param property
   * @param value
   */
  public find(property: string, value: string): HTMLElement[] {
    //このメソッドが返す配列の用意
    let result = new Array();

    //捕捉済みの要素を逐一検索
    for (let i = 0, max = this.elements.length; i < max; i = (i + 1) | 0) {
      //計算済み css が合致していなかったらスルー
      if (window.getComputedStyle(this.elements[i]).getPropertyValue(property) !== value) {
        continue;
      }

      //該当要素として検索結果配列に追加
      result.push(this.elements[i]);
    }

    //該当要素を返す
    return result;
  }

  /**
   * 全要素中で最大の width, もしくは height を返す
   * @param target
   */
  public highSize(target: 'width' | 'height' = 'height'): number{
    //このメソッドが返す数値
    let result: number = 0;

    //捕捉済みの要素を逐一検索
    for (let i = 0, max = this.elements.length; i < max; i = (i + 1) | 0) {
      //サイズの計測対象(width or height)
      const size = target === 'height'
        ? this.elements[i].getBoundingClientRect().height
        : this.elements[i].getBoundingClientRect().width;

      //result 以下だったらスルー
      if (result >= size) {
        continue;
      }

      //最高値を書き換える
      result = size;
    }

    //結果を返す
    return result;
  }

}

page.ts
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //position: fixed を採用している要素
  let fixedElements: HTMLElement[] = [];

  //position: fixed を採用している要素を確保する
  const getFixed = () => {
    const findStyle = new FindStyle(document.body);
    fixedElements = findStyle.find('position', 'fixed');
  }

  //position: fixed を採用している要素を非表示にする or 元に戻す
  const controlFixed = (property: 'hidden' | '') => {
    for (let i = 0, max = fixedElements.length; i < max; i = (i + 1) | 0) {
      fixedElements[i].style.visibility = property;
    }
  };

  //~略~

  //position: fixed を採用している要素の確保
  getFixed();

});

HTML Canvas にキャプチャデータを並べてトリミングする

詳細な説明を後回しにしていた Capturing クラスですが、コードは次のようになっています。

Capturing.ts
interface CaptureURL {
  url: string,
  x: number,
  y: number
}

export class Capturing {

  //キャプチャ済み DataURL の集合
  private captureURLs: CaptureURL[] = [];

  /**
   * target が CanvasRenderingContext2D であるか判定する
   * 具体的には drawImage メソッドが存在するか判定する
   * @param target
   */
  private _isCanvasRenderingContext2D = (target: any): target is CanvasRenderingContext2D => {
    return target.drawImage !== undefined;
  }

  /**
   * 現在 captureURLs に読み込まれているデータをカンバスに読み込み、合成、トリミングする
   * 最終的に吐き出される画像の大きさは width * height となる
   * @private
   */
  public compose = async (width: number, height: number): Promise<string> => {
    //カンバスの作成
    const canvas = document.createElement('canvas');

    //カンバスの大きさを設定
    canvas.setAttribute('width', width+'px');
    canvas.setAttribute('height', height+'px');

    //2D コンテキストを取得
    const ctx = canvas.getContext('2d');

    //ctx のタイプガード
    if ( ! this._isCanvasRenderingContext2D(ctx))
    {
      return '';
    }

    //カンバスに画像を設置
    await this.captureURLs.reduce((prev, current) => prev.then(() => {
      return new Promise(resolve => {
        const image = new Image();
        image.onload = () => {
          ctx.drawImage(image, current.x, current.y);
          resolve();
        };
        image.src = current.url;
      });
    }), Promise.resolve());

    //dataURL を生成
    const data = canvas.toDataURL();

    //canvas を消す
    canvas.remove();

    //dataURL を返す
    return data;
  };

  /**
   * キャプチャを取得し、captureURLs に push する
   * @param x
   * @param y
   * @private
   */
  public capture(x: number, y: number): Promise<void> {
    return new Promise(resolve => {
      chrome.tabs.captureVisibleTab((url) => {
        this.captureURLs.push({x, y, url});
        resolve();
      });
    });
  }

  /**
   * captureURLs を空にする
   */
  public init() {
    this.captureURLs = [];
  }

}

要は現在のスクロール位置を指定しながら capture(x, y) を呼ぶとクラス内部の captureURLs にキャプチャデータが溜まっていき、compose(width, height) で指定した大きさの画像にトリミングして DataURL を返してくれるクラスです。バックグラウンドから呼びます。

capture(x, y) では最初の方に説明した chrome.tabs.captureVisibleTab() API を使用してキャプチャを取得しています。

compose(width, height) は一見 string を返すように見えますが、先述したように async を指定している関数のため、実際にかえる値は TypeScript で指定している通り Promise です。

またバックグラウンドページで実行しているコードとはいえ、node.js とは違いグローバルに document が存在するため、Canvas API が使用可能です。
使用したところでバックグラウンドページなのでユーザーの目に触れることはありませんが、今回は画像合成が目的なので特に問題ありません。

ctx.drawImage で画像を並べた後、ctx.todDataURL() で PNG 画像のバイナリ文字列を手に入れます。

async function - JavaScript | MDN

ファイル名を決定してダウンロードする

最後にバイナリデータを画像ファイルとしてダウンロードしましょう。

ダウンロード自体は先述したように chrome.downloads.download で実現可能ですが、その前に画像ファイルの名前を決める必要があります。

ctx.todDataURL() で引数を設定しなかったので拡張子は .png で確定なのですが、この拡張機能ではファイル名をユーザーが設定できる機能を備えています。
設定画面で扱った title がファイル名となって出力されるのですが、固定文字のみならず変数として使えるテンプレート文字列が以下の三種類です。

  • {{title}}: ページタイトルに変換
  • {{url}}: URLに変換
  • {{counter}}: 連番に変換

これら文字列を該当する文字列に変換しつつ、かつファイル名として使用できない文字を除外する必要があるのですが、これら機能をベタにバックグラウンドスクリプトへ記述すると煩雑なので SigzingCapturing と同じように別クラスへと切り出しました。

Filename.ts
/**
 * ファイルネーム作成クラス
 */
import {Templates} from "./interface";

export class Filename {

  /**
   * 置き換え定義
   */
  private templates: Templates;

  /**
   * ファイル名に使用できない文字を全て replacement に置換して返す
   * @param string
   * @param replacement
   * @return {string}
   * @private
   */
  private _replaceBadCharacter(string: string, replacement: string = '_') {
    return String(string).replace(/[\\\/:\*\?"<>\-\|\s]+/g, replacement);
  }

  /**
   * this.templates の定義
   */
  public constructor() {
    this.templates = new Array();
  }

  /**
   * テンプレート変数文字列とその値を設定する
   * @param template
   * @param value
   */
  public setTemplate(template: string, value: string) {
    this.templates.push({
      template: String(template),
      value: String(value)
    });
  }

  /**
   * setTemplate(), _replaceBadCharacter() で変換したファイル名を出力
   * @param name
   * @return {string}
   */
  public getFileName(name: string): string {
    //テンプレート変数文字列を値に置き換える
    for (let i = 0, max = this.templates.length; i < max; i = (i + 1) | 0) {
      name = String(name).replace(new RegExp(this.templates[i].template, 'g'), this.templates[i].value);
    }

    //使用不可の文字を全て置き換えて返却
    return this._replaceBadCharacter(name);
  }

}

まず、正規表現は使用できませんがString.replace() を使用するような感覚で Filename.setTemplate() で事前に置換対象文字列と置換後の文字列を指定します。

そして getFileName() の引数にユーザーが設定したファイル名文字列(例: '{{title}}_{{counter}}')を仕込んで呼べば拡張子より前のファイル名が取得できます。

background.ts
    /**
     * ファイル名を決定し、ダウンロードを行う
     * @param url
     * @param settings
     */
    const download = (url: string, settings: Settings, tab: chrome.tabs.Tab) => {
        //ファイル名変換用クラス
        const filename = new Filename();

        //ファイル名テンプレート変数文字列登録
        if (settings.title.indexOf('{{title}}') !== -1) {
            filename.setTemplate('{{title}}', decodeURIComponent(String(tab.title)));
        }
        if (settings.title.indexOf('{{url}}') !== -1) {
            filename.setTemplate('{{url}}', String(tab.url).replace(/https?:\/\//, ''));
        }
        if (settings.title.indexOf('{{counter}}') !== -1) {
            filename.setTemplate('{{counter}}', String(settings.counter));
            settings.counter = settings.counter + 1;
        }

        //counter 設定の保存
        chrome.storage.sync.set({counter: settings.counter});

        //ダウンロード
        chrome.downloads.download({url: url, filename: filename.getFileName(settings.title)+'.png'});
    };

このようにファイル名を取得し、chrome.downloads.download を呼んでやれば画像をダウンロードすることができます。

以上で拡張機能完成です。

感想

めんどくせえ!

でもなんだかんだで楽しかったです。

近々 chrome.tabs.captureFullVisibleTab が実装されたら立ち直れないかも。

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

Chrome でなるべく早くスクリーンキャプチャを撮る拡張機能を作りましたが想像以上に面倒でした

ついこの前、Chrome でスクリーンキャプチャをできる限り素早く取得する拡張機能を作成しました。

Immediate Shot サムネイル

Immediate Shot

ある日、会社でディレクターから Chrome で手軽にスクリーンキャプチャを撮る方法はないかと尋ねられ、確か、最近の Chrome だったら……と、開発者ツールを開いてからキャプチャを撮る方法を提案しました。

しかし、ショートカットがあるならまだしも、画面ごとに F12 キーを押して、詳細機能を呼び出し……なんてやるのは、大量のキャプチャを撮らねばならないときは結構な手間です。

聞けば、ちょっと前までは Firefox にワンクリックで画面のキャプチャを撮ってくれる拡張機能があったということだったのですが、Firefox のアップデートによりその拡張機能は使えなくなり、現在 Chrome に公開されている拡張機能も試したものは全て何かしらのワンクッションが挟まってるとのことでした。

なるほど、それだったら趣味も兼ねて Chrome の拡張機能開発に挑戦してみよう、と思い至り、開発に着手しました。

なぁに、拡張機能独自のお約束を理解するのに手間取っても、30分もあればおちゃのこさいさい、Chrome ごと木っ端微塵にしてくれるわ、と。

え、意外とこれ難しくない?

というわけで、この記事はサイト全体のキャプチャを撮るのが意外と面倒だったのでその方法を Chrome の拡張機能として作る方法の共有となっております。

拙い拡張機能ではありますが、Chrome の拡張機能作成の流れを流し読みする目的にもどうぞ。

なお、コードは TypeScript によって開発しましたので、この記事の説明も TypeScript で行います。途中まで Babel で作成してたんですが、よく考えたら Chrome でしか動かさないよねこれ??と気づいたので書き直しました。型も重要ですけど、皆さんも着手前の要件定義・技術選定はしっかりと行いましょう。くわばらくわばら。

先に拡張機能の紹介

まだ改修の余地はありますが、現在実際に動くコードは GitHub に公開しています。

https://github.com/Go-Noji/Immediate-Shot

拡張機能をインストールすると Chrome のアドレスバー横に拡張機能のアイコンが表示されるのですが、このアイコンをクリックするとアクティブなタブのキャプチャが可能な限り速やかに保存されます。

右クリックして選択可能な「オプション」にて拡張機能の設定が可能で、キャプチャ範囲の選択肢として、

  • サイト全体をほぼ完璧にキャプチャする全て(高品質)モード
  • 低画質な代わりにサイト全体を一瞬でキャプチャする全て(高速)モード
  • 現在表示している範囲のみキャプチャする表示中モード

が選択可能です。

更に、ダウンロードされる png ファイルの名前も設定可能で、任意の文字列以外に

  • {{title}}: ページタイトルに変換
  • {{url}}: URLに変換
  • {{counter}}: 連番に変換

という自動で展開される変数も用意してあります。

ボタンが押されてから画像をダウンロードするまでの流れ

本題に入りましょう。
アプリでは拡張機能のアイコンを押す or 右クリックでメニューを呼び出すときにキャプチャが開始されますが、その間に発生する処理の流れは以下の通りです。

  1. 必要な設定を読み込む
  2. 現在表示しているサイトの表示サイズ・全体サイズを算出する
  3. 必要な回数、画面をスクロールしながらキャプチャを行い、画像データを取り溜める
  4. ダウンロードする画像と同じサイズの canvas 要素を生成し、画像を並べる
  5. canvas の toDataURL() を用い、画像を吐き出す
  6. 設定に基づきファイル名を決定、ダウンロードする

何気なく書いてますが、API を多用する関係上幾度となく非同期処理が挟まるため、コードは実質 Promise 祭りです。

また、この流れ以外にも拡張機能の「オプション」を設定・保存するための処理も必要です。

そもそも、今回の拡張機能を作成するにはどのような設計が必要なのか

設計と言葉を濁しましたが、まず最初に Chrome の拡張にはどんなことをする「場」があって、その各「場」に対応するファイルはなんなのかという説明をしたいと思います。

まず、Chrome に対する指示書たる manifest.json について理解しましょう。

詳しくは以下の記事に解説が載っています。僕もこれを見ながら開発しました。

Chrome 拡張機能のマニフェストファイルの書き方

詳細は上記で見てもらうとして、ここで取り上げたい項目が三つあります。

manifest.json
{
    "background": {
        "persistent": false,
        "scripts": ["background.bundle.js"]
    },
    "content_scripts": [{
        "js": ["page.bundle.js"],
        "matches": ["http://*/*", "https://*/*"]
    }],
    "options_ui": {
        "page": "options.html",
        "chrome_style": true
    },
}

上記は実際に作った拡張機能の抜粋になりますが、この三つの項目が先述の「場」に対応する設定となります。

そもそも、拡張機能は JavaScript を動かすことで実行することになるのですが、その JavaScript を動かせる場として

  • バックグラウンド
  • 閲覧中の Web サイト
  • 拡張機能の設定画面

の三種類があり、それぞれが上記コードの

  • background
  • content_scripts
  • options_ui

に対応しています。

それぞれできることが違うのですが、今回の拡張機能では

  • バックグラウンド → 実行のトリガー・キャプチャの実行・ダウンロード
  • 閲覧中の Web サイト → css・スクロール制御
  • 拡張機能の設定画面 → 設定の表示・更新

を行うため、各種 JavaScript を用意します。

設定を保存するための画面を用意する

文言を国際化する

設定画面は自分でデザインすることもできますが、css を用意しなくても Chrome が簡易なスタイルを用意してくれているのでそちらを利用することにします。

manifest.json
{
  ~略~
  "options_ui": {
    "page": "options.html",
    "chrome_style": true
  },
  ~略~
}

上記 json が示している通り、拡張機能のオプション画面が options.html となります。

普通の Web サイトを作成するように作成して問題ないのですが、ここでひと手間掛けたいのが文言の国際化です。

この拡張機能のオプション設定画面は日本語環境では日本語、それ以外では英語で文言を表示するようにしています。

拡張機能の国際化には chrome.i18n という API を使用しますが、残念ながらリンク先は英語一本です。エンジニアたるもの言語に屈してはならないのです

屈した三流園児ニアの僕は以下の記事と Google 翻訳を駆使して開発に臨みました。
Google さんに怒りをぶつけようとしても Google 翻訳というありがたいサービスの前にはただただひれ伏すのみです。
Chromeエクステンションを作ろう:国際化編

_locals/ja/message.json
{
  "msg_desc": {"message": "Immediate Shot はワンクリックで素早くスクリーンショットを撮る Chrome 用拡張機能です。"},
  "msg_saved": {"message": " 設定を保存しました。"},
  "msg_option_title_captureRange": {"message": "範囲"},
  "msg_option_description_full": {"message": "全て(高速)"},
  "msg_option_description_display": {"message": "表示中"},
  "msg_option_description_perfect": {"message": "全て(高品質)"},
  "msg_option_title_fileName": {"message": "ファイル名"},
  "msg_option_title_templates": {"message": "ファイル名変数"},
  "msg_option_description_title": {"message": "ページタイトルに置き換わります"},
  "msg_option_description_url": {"message": "URLに置き換わります"},
  "msg_option_description_counter": {"message": "下記'count'に置き換わります(数値は一つずつ増えていきます)"},
  "msg_option_title_counter": {"message": "カウンター(ファイル名にて{{counter}}として使用)"},
  "msg_option_max": {"message": "チェックするとWebサイトの最大幅を全要素から検索するようになります(全画面スクリーンショットが上手くいかない場合にお試しください)"},
  "msg_option_save": {"message": "保存"}
}
options.html
<!--略-->
  <li>
    <spna><span class="lang" data-key="option_title_captureRange"></span>: </spna>
    <label><input id="perfect" name="range" type="radio" value="perfect"><span class="lang" data-key="option_description_perfect"></span></label>
    <label><input id="full" name="range" type="radio" value="full"><span class="lang" data-key="option_description_full"></span>&nbsp;</label>
    <label><input id="display" name="range" type="radio" value="display"><span class="lang" data-key="option_description_display"></span>&nbsp;</label>
  </li>
<!--略-->
options.ts
  //~略~

  /**
   * lang クラスを持った要素の 'msg_' + data-key属性 から言語メッセージを取得し、要素のテキストを変更する
   */
  const setLang = () => {
    //テキスト変換対象の取得
    const targets = document.getElementsByClassName('lang');

    //変換処理
    for (let i = 0, max = targets.length; i < max; i = (i + 1) | 0) {
      //対象を一旦変数へ挿入
      const target = targets.item(i);

      //対象が存在しなかったらなにもしない
      if (target === null) {
        continue;
      }

      //メッセージキーを一旦変数へ挿入
      const key = target.getAttribute('data-key');

      //メッセージキーが存在しなかったらなにもしない
      if (key === null) {
        continue;
      }

      //テキスト設定
      target.innerHTML = chrome.i18n.getMessage('msg_'+key);
    }
  };

  //~略~

  //言語ごとにテキスト設定
  document.addEventListener('DOMContentLoaded', setLang);

上記のように、_locals/iso 639 言語コード/message.json を用意しておくと chrome.i18n.getMessage で自動判別された言語のメッセージを取得できるので、この拡張機能では class 属性に lang を持つ要素の data-key 属性で翻訳を行うというアプローチをとっています。

設定の取り出し・保存

設定画面のスクリーンショット

設定値は chrome.storage API を通じて取得・更新します。

まずは取得ですが、まだ値を保存していないキーに対しての初期値が必要です。
保存は設定画面でしか行いませんが、取得は拡張機能の至る所で行うので、設定だけ保存しておく TypeScript ファイルを用意しておきます。

config.ts
//キャプチャ範囲初期値
export const DEFAULT_RANGE: string = 'perfect';

//タイトル名初期値
export const DEFAULT_TITLE: string = '{{title}}';

//サイトのマックス値を画面幅だけで取るか、全要素から取得するか
export const DEFAULT_MAX: boolean = false;

//カウント変数初期値
export const DEFAULT_COUNTER: number = 1;

/*----以下二つは関係なし----*/

//複数枚キャプチャの際、次のキャプチャまで何ミリ秒間隔を置くか
export const CAPTURE_WAIT_MILLISECONDS: number = 20;

//CAPTURE_WAIT_MILLISECONDS が使われる際、最初の一回だけはこの値が使用される
export const FIRST_CAPTURE_WAIT_MILLISECONDS: number = 100;

実際に使用する際には、

options.ts
import {DEFAULT_COUNTER, DEFAULT_MAX, DEFAULT_RANGE, DEFAULT_TITLE} from "./config";

  //~略~

  /**
   * 読み取り
   */
  const restore_options = () => {
    chrome.storage.sync.get({
      range: DEFAULT_RANGE,
      title: DEFAULT_TITLE,
      counter: DEFAULT_COUNTER,
      max: DEFAULT_MAX
    }, (items: {[key: string]: string}) => {
      setCheckedRadio(items.range);
      setValue('title', items.title);
      setValue('counter', String(items.counter));
      setCheckedCheckbox('max', Boolean(items.max));
    });

  //~略~

  document.addEventListener('DOMContentLoaded', restore_options);

  //~略~

のように、chrome.storage.sync.get() の第二引数がコールバックになっているので、ここでは item 引数に入ってきた設定値を各要素に描写する関数へ渡しています。

保存する際は逆に各要素から value を読み取り、 chrome.storage.sync.set() で保存を行います。

options.ts
import {DEFAULT_COUNTER, DEFAULT_MAX, DEFAULT_RANGE, DEFAULT_TITLE} from "./config";

  //~略~

  /**
   * 保存
   */
  const save_options = () => {
    //設定の取得(range)
    let range = 'full';
    if (isCheckedRadio('display')) {
      range = 'display';
    }
    else if (isCheckedRadio('perfect')) {
      range = 'perfect';
    }

    //設定の取得(title)
    const title = getValue('title');

    //設定の取得(counter)
    const counter = Number(getValue('counter'));

    //設定の取得(max)
    const max = isCheckedCheckbox('max');

    //保存
    chrome.storage.sync.set({range, title, counter, max}, () => {
      const status = document.getElementById('status');
      if (status === null) {
        return;
      }
      status.textContent = chrome.i18n.getMessage('msg_saved');
      setTimeout(() => {
        status.textContent = '';
      }, 750);
    });
  };

  //~略~

  const save = document.getElementById('save');
  if (save !== null) {
    save.addEventListener('click', save_options);
  }

  //~略~

chrome.storage.sync.set() にも第二引数にコールバックが仕込めるので設定の保存が完了した表示を出すのに利用しています。

スクリーンキャプチャを撮るための前提

仮に、現在写っている画面のみを撮るのであれば

この拡張機能の肝となるキャプチャは「現在写っている部分のみ」ならば以下のようなコードで実現可能です。

manifest.json
{
  ~略~
  "permissions": ["tabs", "downloads"],
  ~略~
}
background.ts
const action = () => {
  chrome.tabs.captureVisibleTab((url) => {
    chrome.downloads.download({url: url, filename: 'capture.png');
  });
};

tabs を許可すると画面キャプチャを base64 データとして取得できる chrome.tabs.captureVisibleTab が、downloads を許可すると直接ファイルをダウンロードできる chrome.downloads.download が使用できます。

後々重要になってくる制約ですが、chrome.tabs.captureVisibleTab は先述したバックグラウンドでのみ使用できる API です。

アドレスバー横のアイコンをクリックした際なら、

background.ts
chrome.browserAction.onClicked.addListener(action);

右クリックメニューに項目を用意するのであれば、

manifest.json
{
  ~略~
  "permissions": ["contextMenus"],
  ~略~
}
background.ts
//メニューの登録
chrome.contextMenus.create({
  id: 'run',
  title: 'Immediate Shot',
  contexts: ['all'],
  type: 'normal'
});

//クリックされたとき
chrome.contextMenus.onClicked.addListener(action);

のようにバックグラウンド用の JavaScript を用意すれば目的は達成できるでしょう。
これらトリガー設定もバックグラウンドのみが行える仕事です。

全面キャプチャを撮るためには画面をスクロールしながらキャプチャを断片的に撮り溜める必要がある

しかし、残念ながら chrome.tabs.captureVisibleTab に対してサイトの全面キャプチャを撮る chrome.tabs.captureFullVisibleTab 的な API は今のところ実装されていません。

それなのに世のキャプチャ系拡張機能はどうやって全面キャプチャを撮っているのかというと、

  1. サイトの一番左上までスクロールする
  2. 現在表示されている範囲をキャプチャする
  3. 表示されていない範囲を撮るために右 or 下へスクロール
  4. 横スクロール&縦スクロールできる範囲を撮りつくすまで 2 → 3 を繰り返す
  5. 取り溜めた各画像を HTML Canvas 上に再度並べ直し、合成して一枚の画像データとして吐き出す
  6. データをダウンロード

という涙ぐましい努力を行うことで全面キャプチャを実現しています。

ロジックを組むだけで眩暈がしそうですが、真に骨が折れるのは上記 2 と 3 の間が非同期処理でやり取りされるという点です。

  • background
  • content_scripts
  • options_ui

この中で唯一出番のなかった content_scripts はここで出てきます。

先述したように、キャプチャを撮る、ボタンクリックをトリガーする等の仕事は background でしかできません。

対して閲覧中の Web サイトに直接働きかける、つまり

  • 要素の情報を取得する(座標やボックスモデル)
  • 要素のスタイルを変更する
  • 画面をスクロールする

等の処理は content_scripts のみが行える仕事です。

つまり、上記フローには両者の相互通信が必要になり、具体的には、

  1. content_scripts が左上へスクロールを行う
  2. background がキャプチャを行う
  3. background から content_script へ次の座標へスクロールの依頼をする
  4. content_scripts はスクロールが完了次第 background へ応答する
  5. 応答を受け、background は再度キャプチャを行う

というやり取りを行うこととなります。
ちょうどバックエンドとフロントエンドの Ajax 通信と似てるので、同じノリで開発することにしましょう。

transform: scale() を使用して一発で全面キャプチャを撮る(低解像度)

ここまで説明しておいてなんですが、画像が荒くてもいいのであれば一発で全面キャプチャを撮る方法があります。
css の transform: scale() を使用して、Web サイト全面を現在の表示領域に収まるまで縮小してキャプチャすればいいのです。

Sizing.ts
import {FindStyle} from "./FindStyle";

export class Sizing {

  //~略~

  /**
   * style タグを挿入する
   * 既にこのクラスが扱っている style が存在した場合はリセットする
   * @param style
   * @private
   */
  private _appendStyle(style: string) {
    //リセット
    this._removeStyle();

    //style タグを用意
    const tag = document.createElement('style');
    tag.setAttribute('id', this.STYLE_ID);
    tag.innerText = style;

    //tag タグ挿入
    document.head.appendChild(tag);
  }

  //~略~

  /**
   * 各種情報をアップデートする
   * @private
   */
  private _updateInformation(max: boolean) {
    //全要素サイズ取得用インスタンス
    let findStyle = new FindStyle(document.getElementsByTagName('html')[0]);

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //幅と高さそれぞれの割合
    const widthRatio = this.windowWidth / this.documentWidth;
    const heightRatio = this.windowHeight / this.documentHeight;

    //ratio と ratioType のセット
    this.ratio = widthRatio > heightRatio ? heightRatio : widthRatio;
    this.ratioType = widthRatio > heightRatio ? 'height' : 'width';

    //ratio が 1 以上だったら 1 とする
    this.ratio = this.ratio > 1 ? 1 : this.ratio;

    //~略~
  }

  /**
   * フルサイズ用のサイジング処理を行う
   */
  public fullSizing(): Coordinates {
    //style タグを生成
    this._appendStyle('body{overflow:hidden;transform-origin: left top;transform: scale('+this.ratio+')}');

    //スクロール位置を 0 にする
    window.scrollTo(0, 0);

    //0, 0 を返す
    return {
      x: 0,
      y: 0
    };
  }

  //~略~
}

上記はスクロールなどの処理を行うクラスの一部ですが、ここでは現在のウィンドウサイズとドキュメントサイズを比較して body の大きさを現在のウィンドウに収まる大きさにしています。
こうしてしまえば画像はかなり小さくなってしまうもののキャプチャが一発で済み非常に高速なので、実際の拡張機能ではモードの一つとして採用しています。

完全な全面キャプチャの撮影処理

現在の Web ページ情報と設定を取得する

キャプチャのために必要な情報は以下の三種類です。

  • 現在開いているタブ
  • そのタブで表示されているページ情報
  • 拡張機能の設定

これら情報を一気に取得するための関数を作成しましょう。

background.ts
import {Information, Settings, Range} from "src/class/interface";
import {Capturing} from "./class/Capturing";
import {Filename} from "./class/Filename";
import './config';
import {CAPTURE_WAIT_MILLISECONDS, DEFAULT_COUNTER, DEFAULT_RANGE, DEFAULT_MAX, DEFAULT_TITLE, FIRST_CAPTURE_WAIT_MILLISECONDS} from "./config";

interface InitData {
  tab: chrome.tabs.Tab,
    settings: Settings,
      information: Information
}

{

  /**
   * 拡張機能の設定と現在参照中のタブ情報を返す
   */
  const init = () => {
    /**
     * range を Range 型にキャストする
     * @param range
     */
    const castRange = (range: string): Range => {
      switch (range) {
        case 'full':
        case 'display':
        case 'perfect':
          return range;
          break;
        default:
          return 'full';
          break;
      }
    };

    return new Promise<InitData>(resolve => {
      //現在開いているタブを入手
      new Promise<chrome.tabs.Tab>(innerResolve => {
        chrome.tabs.query({active: true}, (tabs: chrome.tabs.Tab[]) => {
          innerResolve(tabs[0]);
        });
      })
        .then(tab => {
        //拡張機能の設定を入手
        return new Promise<{tab: chrome.tabs.Tab, settings: Settings}>(innerResolve => {
          chrome.storage.sync.get({range: DEFAULT_RANGE, title: DEFAULT_TITLE, counter: DEFAULT_COUNTER, max: DEFAULT_MAX}, (items: {[key: string]: string}) => {
            innerResolve({tab, settings: {range: castRange(items.range), title: String(items.title), counter: Number(items.counter), max: Boolean(items.max)}});
          });
        });
      })
        .then((data: {tab: chrome.tabs.Tab, settings: Settings}) => {
        //現合表示しているタブの情報を入手
        chrome.tabs.sendMessage(Number(data.tab.id), {type: 'information', max: data.settings.max}, (information: Information) => {
          resolve({tab: data.tab, settings: data.settings, information});
        });
      });
    });
  };

  //~略~

}

Promise だらけですが、要は各 Promise で上記情報を取得しています。

Google が提供する API は先ほども登場した chrome.storage.sync.get() のようにコールバックを引数に仕込むことで非同期処理を実現するものが殆どです。

しかし API を複数実行する場合は典型的なコールバック地獄になるため、各 API を自前の Promise でラップしてあげるのが基本方針となります。

最終的に init() 自体もそれら全てをラップした Promise を返すようにすることでこれら情報をひとまとめに受け取るということですね。

さて、ここで注目したいのが実際に表示しているページの情報を取得している、

background.ts
chrome.tabs.sendMessage(Number(data.tab.id), {type: 'information', max: data.settings.max}, (information: Information) => {
  resolve({tab: data.tab, settings: data.settings, information});
});

の部分です。

chrome.tabs.sendMessage() は第一引数で指定したタブで動いている content_scripts に対して第二引数のオブジェクトを渡す API です。

第三引数は content_scripts から返ってきたオブジェクトを受け取るコールバックとなっています。

さっそく今回の拡張機能における受け取り側を見てみましょう。

page.ts
import {Range, Coordinates} from "./class/interface";
import {Sizing} from "./class/Sizing";
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //ここに関数や変数を定義する

  //~略~

  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 受け取った値で分岐
    switch (request.type) {
      case 'information':
        sendResponse(information(request.max));
        break;
      case 'sizing':
        sendResponse(styling(request.range, request.index, request.max));
        break;
      case 'killFixed':
        controlFixed('hidden');
        sendResponse({});
        break;
      case 'resetSizing':
        controlFixed('');
        resetSizing({x: request.x, y: request.y});
        sendResponse({});
        break;
      default:
        sendResponse({});
        break;
    }
  });


  //~略~

  //ここに関数や変数を定義する

  //~略~

});


バックグラウンドページと違い、 content_scripts として登録された .js ファイルは通常の Web サイトに <script> タグで埋め込んだ JavaScript ファイルと同じようにふるまいます。

しかし特異かつ肝なのは下記の部分です。

page.ts
  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {}

先ほどの chrome.tabs.sendMessage がバックグラウンドから呼ばれると、この chrome.runtime.onMessage イベントに登録された関数が実行されます。

このイベントに登録された関数は三つの引数を受け取りますが、今回用があるのはバックグラウンドで chrome.tabs.sendMessage の第二引数に仕込んだ情報である第一引数(request)と、レスポンスを返すときに使う関数である第三引数(sendResponse)です。

chrome.runtime - Google Chrome (onMessage イベント)

上記にもある通り、request の中に type として仕込んだ string で実際に起動する関数を選択し、その返り値を sendResponse() でバックグランドページに返してあげれば複数の要件で二つのスクリプト間のメッセージングが可能となるわけです。

今回バックグラウンドから呼ばれた要求は type :information なので、page.tsinformation() を見てみましょう。

page.ts
  //サイズを取得するためのクラス
  const sizing = new Sizing();

  //~略~

  //表示されているタブの情報を返す
  const information = (max: boolean) => {
    return sizing.getInformation(max);
  };

Sizing.ts
import {Coordinates, Information} from "src/class/interface";
import {FindStyle} from "./FindStyle";

export class Sizing {

  //constructor() 時点の window width
  private windowWidth: number = 0;

  //constructor() 時点の window height
  private windowHeight: number = 0;

  //constructor() 時点の document width
  private documentWidth: number = 0;

  //constructor() 時点の document height
  private documentHeight: number = 0;

  //画面縮小比率
  private ratio: number = 0;

  //画面を幅と高さのどちらで縮小したか
  private ratioType: 'width' | 'height' = 'height';

  //documentWidth を現在の windowWidth の大きさでキャプチャするには横に何枚キャプチャが必要か
  private widthCaptureNumber: number = 0;

  //documentHeight を現在の windowHeight の大きさでキャプチャするには縦に何枚キャプチャが必要か
  private heightCaptureNumber: number = 0;

  //上記二つの乗算値
  private captureNumber: number = 0;

  //constructor() 時点のスクロール位置(横)
  private scrollX: number = 0;

  //constructor() 時点のスクロール位置(縦)
  private scrollY: number = 0;

  /**
   * 各種情報をアップデートする
   * @private
   */
  private _updateInformation(max: boolean) {
    //全要素サイズ取得用インスタンス
    let findStyle = new FindStyle(document.getElementsByTagName('html')[0]);

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //幅と高さそれぞれの割合
    const widthRatio = this.windowWidth / this.documentWidth;
    const heightRatio = this.windowHeight / this.documentHeight;

    //ratio と ratioType のセット
    this.ratio = widthRatio > heightRatio ? heightRatio : widthRatio;
    this.ratioType = widthRatio > heightRatio ? 'height' : 'width';

    //ratio が 1 以上だったら 1 とする
    this.ratio = this.ratio > 1 ? 1 : this.ratio;

    //縦と横においてそれぞれ現在のウィンドウサイズ何枚分で全画面を捕捉できるかの数値を算出
    this.widthCaptureNumber = Math.ceil(this.documentWidth / this.windowWidth);
    this.heightCaptureNumber = Math.ceil(this.documentHeight / this.windowHeight);

    //上記二つの乗算値
    this.captureNumber = this.widthCaptureNumber * this.heightCaptureNumber;

    //現在のスクロール座標を記録
    this.scrollX = window.scrollX;
    this.scrollY = window.scrollY;
  }

  /**
   * 情報を返す
   * @return {{documentWidth: number | *, documentHeight: number | *, windowHeight: number | *, ratioType: string, windowWidth: number | *, ratio: (*|number)}}
   */
  public getInformation(max: boolean = false): Information {
    //情報の更新
    this._updateInformation(max);

    //計算結果を返す
    return {
      windowWidth: this.windowWidth,
      windowHeight: this.windowHeight,
      documentWidth: this.documentWidth,
      documentHeight: this.documentHeight,
      widthCaptureNumber: this.widthCaptureNumber,
      heightCaptureNumber: this.heightCaptureNumber,
      captureNumber: this.captureNumber,
      ratio: this.ratio,
      ratioType: this.ratioType,
      scrollX: this.scrollX,
      scrollY: this.scrollY

  }

//~略~

}

怯んではなりません。後々これだけの情報が必要なのです。

ただし、コードを書いている身としてもこれら情報セットは複雑なのでバグ防止・コード補完のために TypeScript の interfaceInformation を作成し、各コードで使いまわしています。

interface.ts
//~略~
export interface Information {
  windowWidth: number,
  windowHeight: number,
  documentWidth: number,
  documentHeight: number,
  widthCaptureNumber: number,
  heightCaptureNumber: number,
  captureNumber: number,
  ratio: number,
  ratioType: string,
  scrollX: number,
  scrollY: number
}
//~略~

必要なキャプチャ枚数とスクロール座標を算出する

init() で必要な情報は受け取ったので、今度は必要なキャプチャ枚数と、それぞれのスクロール座標を算出しましょう。

大体のサイトは縦長で縦スクロールしか存在しませんが、当然ほとんどの Web ブラウザは二次元の表示領域を持つので横スクロールのことを無視するわけにはいきません。

縦スクロールと横スクロールそれぞれを勘案に入れて、現在表示している Web ページは何枚のキャプチャが必要でしょう?

板チョコを想像してみてください。

板チョコ.png

板チョコ1ピース分が 現在のウィンドウサイズ、つまり見えている部分の大きさだとしましょう。

上の図の二つ目を例にとると、左上に重ねた板チョコ1枚分では Web サイトの横幅全てをカバーすることはできませんが、2枚あれば横幅をカバーすることができそうです。
同様に、縦幅は5枚あればカバーできそうなので、板チョコは計10枚必要であることが分かります。

先ほどは説明を飛ばしましたが、 Information に定義されている captureNumber はまさにこの総枚数に当たり、同様に widthCaptureNumberheightCaptureNumber はそれぞれ行数と列数になります。

Sizing.ts
  private _updateInformation(max: boolean) {
    //~略~

    //ウィンドウサイズ
    this.windowWidth = window.innerWidth;
    this.windowHeight = window.innerHeight;

    //ドキュメントサイズの最大値を取得するリスト
    let widthSources = [document.body.clientWidth, document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth];
    let heightSources = [document.body.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight];

    //もし max === true だったら取得リストに全要素の最大値を加える
    if (max) {
      widthSources.push(findStyle.highSize('width'));
      heightSources.push(findStyle.highSize('height'));
    }

    //ドキュメントサイズ
    this.documentWidth = Math.max(...widthSources);
    this.documentHeight = Math.max(...heightSources);

    //~略~

    //縦と横においてそれぞれ現在のウィンドウサイズ何枚分で全画面を捕捉できるかの数値を算出
    this.widthCaptureNumber = Math.ceil(this.documentWidth / this.windowWidth);
    this.heightCaptureNumber = Math.ceil(this.documentHeight / this.windowHeight);

    //上記二つの乗算値
    this.captureNumber = this.widthCaptureNumber * this.heightCaptureNumber;

    //~略~
  }

windowWidthwindowHeight がそれぞれ板チョコ1ピース分の widht, height となるので、これら情報でキャプチャ枚数と、それぞれのスクロール座標情報が算出することが可能になりました。

実際にスクロールさせる各板チョコピースの左上座標を算出するには以下の関数を通すと算出できます。

Sizing.ts
  /**
   * 指定された index からスクロールすべき座標を返す
   * @param index
   * @private
   */
  private _getScrollCoordinates(index: number): Coordinates {
    return {
      x: Math.floor(index % this.widthCaptureNumber) % this.captureNumber * this.windowWidth,
      y: Math.floor(index / this.widthCaptureNumber) % this.captureNumber * this.windowHeight
    };
  }

index には処理するピースのインデックス番号を入れるのですが、上記画像の計10枚を例にとるなら、

0 1
2 3
4 5
6 7
8 9

という位置のスクロール座標を返します。

4列3行なら、

0 1 2 3
4 5 6 7
8 9 10 11

といった具合となります。

通常のスクロールではなく transition: translate で疑似的なスクロールを行いながらキャプチャを行う

キャプチャのための情報が出そろったところで素直に window.scrollTo(x, y) 等でスクロールを行ってみると右端・下端でキャプチャが上手くいきません。

当然ながら画面のスクロールは Web サイトの最大幅を超過して行うことはできないからです。

先ほどの板チョコ図を見てみましょう。

板チョコ (1).png

理想としては左図のようにキャプチャしたいところですが、実際にスクロールした場合座標が指定した位置よりも手前で止まってしまい、右端・下端のキャプチャは右図のようにすぐ隣の領域と被ってしまうのです。

また地味に困るのが、Web サイトによってはスクロールによってサイトのデザインが変化する場合があることです。
特に顕著なのがスクロールに応じて固定されたヘッダーが出たり引っ込んだりするサイトです。このようなサイトはいざ下の方のキャプチャを行おうとした際に余計なヘッダーが出現し、後で画像を合成したときに空中に浮いているヘッダーが描画されてしまい、とても見づらいキャプチャ画像となってしまいます。

これらに対処するため、実際にキャプチャ範囲を移動する際は body タグに対して tranform: translate(-x, -y); を指定して疑似的にスクロールを行います。

x座標、y座標共に window.scrollTo(x, y) で指定する値に -1 を掛けた数値を使用しないと逆方向に移動してしまうのに注意しましょう。

translate() - CSS: カスケーディングスタイルシート | MDN

ついでにスクロールバーも画像合成の場合に邪魔になるので overflow: hidden を同じく body タグに指定します。

Sizing.ts
  /**
   * このクラスが仕込んだ style タグを削除する
   * @private
   */
  private _removeStyle() {
    //削除対象の取得
    const target = document.getElementById(this.STYLE_ID);

    //target が存在しなかったら何もしない
    if (target === null) {
      return;
    }

    //対象を削除する
    target.remove();
  }

  /**
   * style タグを挿入する
   * 既にこのクラスが扱っている style が存在した場合はリセットする
   * @param style
   * @private
   */
  private _appendStyle(style: string) {
    //リセット
    this._removeStyle();

    //style タグを用意
    const tag = document.createElement('style');
    tag.setAttribute('id', this.STYLE_ID);
    tag.innerText = style;

    //tag タグ挿入
    document.head.appendChild(tag);
  }

  /**
   * 指定された index からスクロールすべき座標を返す
   * @param index
   * @private
   */
  private _getScrollCoordinates(index: number): Coordinates {
    return {
      x: Math.floor(index % this.widthCaptureNumber) % this.captureNumber * this.windowWidth,
      y: Math.floor(index / this.widthCaptureNumber) % this.captureNumber * this.windowHeight
    };
  }

  //~略~

  /**
   * スクロールバーを消すだけのサイジング処理を行う
   * スクロール位置は index 番号で指定する
   * index が null だった場合はスクロールを変更しない
   * この index 番号は getInformation() で取得できる captureNumber の範囲で指定し、
   * 例えば
   * widthCaptureNumber = 4
   * heightCaptureNumber = 3
   * captureNumber = 12
   * だった場合は
   * +----+----+----+----+
   * |  0  |  1  |  2  |  3  |
   * +----+----+----+----+
   * |  4  |  5  |  6  |  7  |
   * +----+----+----+----+
   * |  8  |  9  | 10 | 11 |
   * +----+----+----+----+
   * といった各マスの左上座標へスクロールすることになる
   * 各マスの width, height = windowWidth, windowHeight
   * 大枠の width, height = documentWidth, documentHeight
   */
  public displaySizing(index: number|null = null, max: boolean = false): Coordinates {
    //index 指定が無かったら style タグを適用の後、(0, 0)を返す
    if (index === null) {
      //style タグを生成
      this._appendStyle('body{overflow:hidden}');

      //(0, 0)返す
      return {
        x: 0,
        y: 0
      };
    }

    //もし index が 0 だったらスクロール位置を 0, 0 にする
    if (index === 0) {
      document.getElementsByTagName('html')[0].scrollTo(0, 0);
    }

    //移動先座標の定義
    const coordinates = this._getScrollCoordinates(index);

    //overflow スタイルの適用 & transform: translate による疑似的なスクロールの実行
    this._appendStyle(max
      ? 'body{overflow:hidden;transform:translate('+(coordinates.x * -1)+'px,'+(coordinates.y * -1)+'px);width: '+this.documentWidth+'px;height: '+this.documentHeight+'px;}'
      : 'body{overflow:hidden;transform:translate('+(coordinates.x * -1)+'px,'+(coordinates.y * -1)+'px)}'
    );

    //スクロール情報を返す
    return coordinates;
  }

この Sizing.displaySizing() を手に入れたキャプチャ定義ごとに呼べば望み通りの位置に画面が移動します。

page.ts
import {Range, Coordinates} from "./class/interface";
import {Sizing} from "./class/Sizing";
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //ブラウザの大きさを適切なものに変える
  const styling = (range: Range, index: number, max: boolean) => {
    //処理終了後の座標情報
    let coordinate: Coordinates = {
      x: 0,
      y: 0
    };

    //range によって処理を分ける
    switch (range) {
      case 'full':
        coordinate = sizing.fullSizing();
        break;
      case 'perfect':
        coordinate = sizing.displaySizing(index, max);
        break;
      default:
        coordinate = sizing.displaySizing(null);
        break;
    }

    //座標情報を返す
    return coordinate;
  };

  //~略~

  //メッセージパッシング
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 受け取った値で分岐
    //~略~
      case 'sizing':
        sendResponse(styling(request.range, request.index, request.max));
        break;
    //~略~
  });

  //~略~

});

background.ts
import {Information, Settings, Range} from "src/class/interface";
import {Capturing} from "./class/Capturing";
import './config';
import {CAPTURE_WAIT_MILLISECONDS, DEFAULT_COUNTER, DEFAULT_RANGE, DEFAULT_MAX, DEFAULT_TITLE, FIRST_CAPTURE_WAIT_MILLISECONDS} from "./config";

{
  //Capturing クラス
  const capturing = new Capturing();

  //~略~

  /**
     * 現在表示しているタブのキャプチャを一回行う
     * @param id
     * @param range
     */
  const createCapture = (id: number, range: Range, index: number, max: boolean = false): Promise<void> => {
    return  new Promise(resolve => {
      //index === 1 (キャプチャが二回目)の場合は position: fixed の要素を非表示にする
      if (index === 1) {
        chrome.tabs.sendMessage(id, {type: 'killFixed'});
      }

      //スクロール・キャプチャ
      chrome.tabs.sendMessage(id, {type: 'sizing', range: range, index: index, max: max}, response => {
        setTimeout(() => {
          capturing.capture(response.x, response.y)
            .then(() => {
            resolve();
          });
        }, index < 2 ? FIRST_CAPTURE_WAIT_MILLISECONDS : CAPTURE_WAIT_MILLISECONDS);
      });
    });
  };

  /**
     * settings と information から求められている画像サイズを導き出す
     * @param settings
     * @param information
     */
  const getImageSize = (settings: Settings, information: Information): {width: number, height: number} => {
    //最終的な画像サイズを決定(この時点では range = display 用)
    let width = information.windowWidth;
    let height = information.windowHeight;

    //range に合わせた画像サイズを用意
    switch (settings.range) {
      case 'full':
        width = information.ratioType === 'width'
          ? information.windowWidth
        : information.windowWidth * information.ratio;
        height = information.ratioType === 'height'
          ? information.windowHeight
        : information.windowHeight * information.ratio;
        break;
      case 'perfect':
        width = information.documentWidth;
        height = information.documentHeight;
        break;
    }

    //返す
    return  {width, height};
  };

  /**
     * 現在開いているタブのキャプチャを行う
     * @param settings
     * @param information
     * @param tab
     */
  const getDataURL = async (settings: Settings, information: Information, tab: chrome.tabs.Tab) => {
    //何枚の画像をキャプチャするか
    const captureNumber = settings.range === 'perfect'
    ? information.captureNumber
    : 1;

    //サイズ取得
    const size = getImageSize(settings, information);

    //キャプチャ処理を必要な回数だけ行う
    for (let i = 0, max = captureNumber; i < max; i = (i + 1) | 0) {
      await createCapture(Number(tab.id), settings.range, i, settings.max);
    }

    //スタイルを元に戻す
    chrome.tabs.sendMessage(Number(tab.id), {type: 'resetSizing', x: information.scrollX, y: information.scrollY});

    //dataURL 化
    return capturing.compose(size.width, size.height);
  };

  //~略~

}

Caputuring クラスは後で説明しますが、 capture() でキャプチャした画像データをクラス内に溜めておき、compose で合成した画像データを返す役割を担っています。

このキャプチャを行う処理は直列の非同期処理ですが、画面条件によって何枚のキャプチャが必要になるかは動的に決定されるため、then() による Promise チェーンを繋ぐコードが静的に書けません。

このように動的な数の非同期処理を直列実行する場合は Array.reduce() を駆使するのが定番ですが、今回は全ブラウザに対応する必要が無い拡張機能の制作ということで async/await を使用してもう少し分かりやすく実装することにしました。

getDataURL()async がつけてあるので内部で await が使用可能になり、

background.ts
for (let i = 0, max = captureNumber; i < max; i = (i + 1) | 0) {
  await createCapture(Number(tab.id), settings.range, i, settings.max);
}

の部分は前の createCapture() が終了(= Promise.resolve() が返るまで)するまで次の createCapture() の実行が待たれるようになります。

スクロールバーと position: fixed を非表示にする

先ほども軽く触れましたが、スクロールに関する問題を解決してもやはり固定された要素は厄介です。

サイト上部へ常に固定されているヘッダーをはじめ、サイト右下に居座る TOP へ戻るボタンや、妙なタイミングで出てくるモーダルウィンドウ等、こういった要素は 宇宙に存在する全ての position: fixed を許さない過激派 のみならず、サイトのキャプチャを撮ろうとする我々開発者をも憎悪の渦に叩きこんできました。

これら要素のうち大抵のものは先述の疑似スクロールによって固定されずに済むものの、やはり2枚目以降のキャプチャ取得時には非表示にしたいものです。

なので、この拡張機能では愚直に全要素を取得し、postion: fixed が適用されている要素のみ一時的に visibility: hidden で非表示にしています。

実装前はかなり重たい処理になるかと思われましたが、実際に作ってみると大抵のサイトで 0.1 秒もかからず全ての要素を非表示にすることができたので、以下のコードを採用しました。
自分で言うのもなんですが、これだけで別の拡張機能が作れそうですね。

FindStyle.ts
export class FindStyle {

  /**
   * constructor で挿入する HTMLElement
   * この要素にぶら下がっている DOM ツリーが対象
   */
  readonly root: HTMLElement;

  /**
   * root 下の全 HTMLElement
   * 階層楮は無く、一次元配列として捕捉
   */
  private elements: HTMLElement[];

  /**
   * target が null でないことを保証する Type guard
   * HTMLElement.children から取ってきたオブジェクトに対して用いる
   * @param target
   * @private
   */
  private _isHTMLElement(target: any): target is HTMLElement {
    return  target !== null;
  }

  /**
   * parent 下にぶら下がる DOM ツリーを再帰的に取得し、this.elements に追加する
   * @param parent
   * @private
   */
  private _findChildren(parent: HTMLElement) {
    //自身をpush
    this.elements.push(parent);

    //子要素の取得
    const children = parent.children;

    for (let i = 0, max = children.length; i < max; i = (i + 1) | 0) {
      //タイプガードを通すため、一旦変数へ格納
      const target = children.item(i);

      //target が null でないことを保証
      if ( ! this._isHTMLElement(target)) {
        continue;
      }

      //再帰的にこの関数を呼ぶ
      this._findChildren(target);
    }
  }

  /**
   * ドキュメントルートを確保し、検索対象の要素を捕捉する
   * @param root
   */
  constructor(root: HTMLElement) {
    //検索対象ツリーの親要素を登録
    this.root = root;

    //検索結果配列を初期化
    this.elements = new Array();

    //検索開始
    this._findChildren(root);
  }

  /**
   * css として property: value が適用されている要素を this.elements から取得する
   * @param property
   * @param value
   */
  public find(property: string, value: string): HTMLElement[] {
    //このメソッドが返す配列の用意
    let result = new Array();

    //捕捉済みの要素を逐一検索
    for (let i = 0, max = this.elements.length; i < max; i = (i + 1) | 0) {
      //計算済み css が合致していなかったらスルー
      if (window.getComputedStyle(this.elements[i]).getPropertyValue(property) !== value) {
        continue;
      }

      //該当要素として検索結果配列に追加
      result.push(this.elements[i]);
    }

    //該当要素を返す
    return result;
  }

  /**
   * 全要素中で最大の width, もしくは height を返す
   * @param target
   */
  public highSize(target: 'width' | 'height' = 'height'): number{
    //このメソッドが返す数値
    let result: number = 0;

    //捕捉済みの要素を逐一検索
    for (let i = 0, max = this.elements.length; i < max; i = (i + 1) | 0) {
      //サイズの計測対象(width or height)
      const size = target === 'height'
        ? this.elements[i].getBoundingClientRect().height
        : this.elements[i].getBoundingClientRect().width;

      //result 以下だったらスルー
      if (result >= size) {
        continue;
      }

      //最高値を書き換える
      result = size;
    }

    //結果を返す
    return result;
  }

}

page.ts
import {FindStyle} from "./class/FindStyle";
window.addEventListener('load', () => {

  //~略~

  //position: fixed を採用している要素
  let fixedElements: HTMLElement[] = [];

  //position: fixed を採用している要素を確保する
  const getFixed = () => {
    const findStyle = new FindStyle(document.body);
    fixedElements = findStyle.find('position', 'fixed');
  }

  //position: fixed を採用している要素を非表示にする or 元に戻す
  const controlFixed = (property: 'hidden' | '') => {
    for (let i = 0, max = fixedElements.length; i < max; i = (i + 1) | 0) {
      fixedElements[i].style.visibility = property;
    }
  };

  //~略~

  //position: fixed を採用している要素の確保
  getFixed();

});

HTML Canvas にキャプチャデータを並べてトリミングする

詳細な説明を後回しにしていた Capturing クラスですが、コードは次のようになっています。

Capturing.ts
interface CaptureURL {
  url: string,
  x: number,
  y: number
}

export class Capturing {

  //キャプチャ済み DataURL の集合
  private captureURLs: CaptureURL[] = [];

  /**
   * target が CanvasRenderingContext2D であるか判定する
   * 具体的には drawImage メソッドが存在するか判定する
   * @param target
   */
  private _isCanvasRenderingContext2D = (target: any): target is CanvasRenderingContext2D => {
    return target.drawImage !== undefined;
  }

  /**
   * 現在 captureURLs に読み込まれているデータをカンバスに読み込み、合成、トリミングする
   * 最終的に吐き出される画像の大きさは width * height となる
   * @private
   */
  public compose = async (width: number, height: number): Promise<string> => {
    //カンバスの作成
    const canvas = document.createElement('canvas');

    //カンバスの大きさを設定
    canvas.setAttribute('width', width+'px');
    canvas.setAttribute('height', height+'px');

    //2D コンテキストを取得
    const ctx = canvas.getContext('2d');

    //ctx のタイプガード
    if ( ! this._isCanvasRenderingContext2D(ctx))
    {
      return '';
    }

    //カンバスに画像を設置
    await this.captureURLs.reduce((prev, current) => prev.then(() => {
      return new Promise(resolve => {
        const image = new Image();
        image.onload = () => {
          ctx.drawImage(image, current.x, current.y);
          resolve();
        };
        image.src = current.url;
      });
    }), Promise.resolve());

    //dataURL を生成
    const data = canvas.toDataURL();

    //canvas を消す
    canvas.remove();

    //dataURL を返す
    return data;
  };

  /**
   * キャプチャを取得し、captureURLs に push する
   * @param x
   * @param y
   * @private
   */
  public capture(x: number, y: number): Promise<void> {
    return new Promise(resolve => {
      chrome.tabs.captureVisibleTab((url) => {
        this.captureURLs.push({x, y, url});
        resolve();
      });
    });
  }

  /**
   * captureURLs を空にする
   */
  public init() {
    this.captureURLs = [];
  }

}

要は現在のスクロール位置を指定しながら capture(x, y) を呼ぶとクラス内部の captureURLs にキャプチャデータが溜まっていき、compose(width, height) で指定した大きさの画像にトリミングして DataURL を返してくれるクラスです。バックグラウンドから呼びます。

capture(x, y) では最初の方に説明した chrome.tabs.captureVisibleTab() API を使用してキャプチャを取得しています。

compose(width, height) は一見 string を返すように見えますが、先述したように async を指定している関数のため、実際に買える値は TypeScript で指定している通り Promise です。

またバックグラウンドページで実行しているコードとはいえ、node.js とは違いグローバルに document が存在するため、Canvas API が使用可能です。
使用したところでバックグラウンドページなのでユーザーの目に触れることはありませんが、今回は画像合成が目的なので特に問題ありません。

ctx.drawImage で画像を並べた後、ctx.todDataURL() で PNG 画像のバイナリ文字列を手に入れます。

async function - JavaScript | MDN

ファイル名を決定してダウンロードする

最後にバイナリデータを画像ファイルとしてダウンロードしましょう。

ダウンロード自体は先述したように chrome.downloads.download で実現可能ですが、その前に画像ファイルの名前を決める必要があります。

ctx.todDataURL() で引数を設定しなかったので拡張子は .png で確定なのですが、この拡張機能ではファイル名をユーザーが設定できる機能を備えています。
設定画面で扱った title がファイル名となって出力されるのですが、固定文字のみならず変数として使えるテンプレート文字列が以下の三種類です。

  • {{title}}: ページタイトルに変換
  • {{url}}: URLに変換
  • {{counter}}: 連番に変換

これら文字列を該当する文字列に変換しつつ、かつファイル名として使用できない文字を除外する必要があるのですが、これら機能をベタにバックグラウンドスクリプトへ記述すると煩雑なので SigzingCapturing と同じように別クラスへと切り出しました。

Filename.ts
/**
 * ファイルネーム作成クラス
 */
import {Templates} from "./interface";

export class Filename {

  /**
   * 置き換え定義
   */
  private templates: Templates;

  /**
   * ファイル名に使用できない文字を全て replacement に置換して返す
   * @param string
   * @param replacement
   * @return {string}
   * @private
   */
  private _replaceBadCharacter(string: string, replacement: string = '_') {
    return String(string).replace(/[\\\/:\*\?"<>\-\|\s]+/g, replacement);
  }

  /**
   * this.templates の定義
   */
  public constructor() {
    this.templates = new Array();
  }

  /**
   * テンプレート変数文字列とその値を設定する
   * @param template
   * @param value
   */
  public setTemplate(template: string, value: string) {
    this.templates.push({
      template: String(template),
      value: String(value)
    });
  }

  /**
   * setTemplate(), _replaceBadCharacter() で変換したファイル名を出力
   * @param name
   * @return {string}
   */
  public getFileName(name: string): string {
    //テンプレート変数文字列を値に置き換える
    for (let i = 0, max = this.templates.length; i < max; i = (i + 1) | 0) {
      name = String(name).replace(new RegExp(this.templates[i].template, 'g'), this.templates[i].value);
    }

    //使用不可の文字を全て置き換えて返却
    return this._replaceBadCharacter(name);
  }

}

まず、正規表現は使用できませんがString.replace() を使用するような感覚で Filename.setTemplate() で事前に置換対象文字列と置換後の文字列を指定します。

そして getFileName() の引数にユーザーが設定したファイル名文字列(例: '{{title}}_{{counter}}')を仕込んで呼べば拡張子より前のファイル名が取得できます。

background.ts
    /**
     * ファイル名を決定し、ダウンロードを行う
     * @param url
     * @param settings
     */
    const download = (url: string, settings: Settings, tab: chrome.tabs.Tab) => {
        //ファイル名変換用クラス
        const filename = new Filename();

        //ファイル名テンプレート変数文字列登録
        if (settings.title.indexOf('{{title}}') !== -1) {
            filename.setTemplate('{{title}}', decodeURIComponent(String(tab.title)));
        }
        if (settings.title.indexOf('{{url}}') !== -1) {
            filename.setTemplate('{{url}}', String(tab.url).replace(/https?:\/\//, ''));
        }
        if (settings.title.indexOf('{{counter}}') !== -1) {
            filename.setTemplate('{{counter}}', String(settings.counter));
            settings.counter = settings.counter + 1;
        }

        //counter 設定の保存
        chrome.storage.sync.set({counter: settings.counter});

        //ダウンロード
        chrome.downloads.download({url: url, filename: filename.getFileName(settings.title)+'.png'});
    };

このようにファイル名を取得し、chrome.downloads.download を呼んでやれば画像をダウンロードすることができます。

以上で拡張機能完成です。

感想

めんどくせえ!

でもなんだかんだで楽しかったです。

近々 chrome.tabs.captureFullVisibleTab が実装されたら立ち直れないかも。

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

【Nuxt.js】asyncDataの引数はContextというオブジェクトです

asyncDataの引数について

Nuxt.jsでよく使われる「asyncData()」メソッドの引数には、
Contextというオブジェクトが渡されてきます。

分割代入の例

index.vue
export default {
  async asyncData ({ params }) {  // ここでContextが引数に渡されてくる
    let { data } = await axios.get(`https://api-test/posts/${params.id}`)
    return { name: data.name }
  }
}

参照:公式ドキュメント/非同期なデータ

Contextとは

Contextには、paramsstoreroute等の
asyncData内で使用できるオブジェクトが入っています。
Contextの詳細については、公式ドキュメント/Contextに詳しく載っています。

asyncData({ params })の{}はES6の分割代入

引数の{}のなかにContextの中身を書いているのは、
下記のような記法を利用しているためです。

index.js
  const vegitables = { tomato: 1, lettuce: 2, eggplant: 3 }
  const { tomato } = vegitables

  console.log(tomato) // 「1」が出力される

この記事を書いた経緯

Nuxt.jsで開発を始めた時、asyncDataの引数が何なのか理解に時間がかかったため、
分かりやすくまとめてあるサイトがあるといいなと思ったためです。
公式ドキュメント/非同期なデータにasyncDataの引数についての記載はありますが、
理解に時間がかかりました。

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

Electronで雑にアプリを作ってWindowsのポータブルアプリとして動くまでやる。

久しぶりにElectronについてお話します。

exeファイルにして動かしたい。

しかし2015年とか2016年の記事がおおい。つらい。令和だぞ。

というわけで今回はElectronで適当にアプリを作ってポータブルアプリとしてすぐ使えるようにするところを目標に作っていこうと思います。

何作る。

marqueeタグで?を流すだけのアプリ。かんたん。

作り方

セットアップ npm init

セットアップは自分が書いた過去の記事と同じことしてます。ここ

適当にフォルダを作成する。

作ったフォルダの中でShift+右クリックPowerShell ウィンドウをここに開くコマンド ウィンドウをここで開くを押します。(Win10は前者。それ以外は後者)。

開いたら中で以下の文を入力します。

npm init -y

これでpackage.jsonが作成できていれば成功です。

そしたらpackage.jsonを開いて、少し書き換えます。

"main" : "index.js",

"main": "./src/main.js",

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },

 "scripts": {
    "start": "electron ."
  },

セットアップ npm install --save-dev electron

PowerShellまたはコマンドプロンプトの画面はそのまま、次の文を入力しましょう。

npm install --save-dev electron

セットアップ 好きなエディタを開いて

最初に作ったフォルダの中にsrcフォルダを作成してください。(スクリーンショットにicon.icoがありますが気にせず。)
image.png

作成したら中に。
package.json
index.html
main.js

それぞれ

package.json
{
    "main": "main.js"
}
index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>寿司が流れるだけのアプリ</title>
</head>

<body style="-webkit-app-region: drag;background-color: rgba(157, 204, 224  , .7)">
    <!-- 寿司が流れるだけ -->
    <div style="padding: 20px" class="center">
        <marquee id="marquee" scrollamount="25">
            <font id="text" size="7">?</font>
        </marquee>
    </div>
</body>

</html>
main.js
// Modules to control application life and create native browser window
const { app, BrowserWindow, Menu } = require('electron')

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow

function createWindow() {
    // Create the browser window.
    mainWindow = new BrowserWindow({
        width: 300,         //横
        height: 150,        //縦
        frame: false,       //フレームなくす
        transparent: true,   //背景透明化
        alwaysOnTop: true,         //最前面
        webPreferences: {
            nodeIntegration: true   //これ書く。
        }
    })

    // and load the index.html of the app.
    mainWindow.loadFile('./src/index.html')
    //メニューバー削除
    Menu.setApplicationMenu(null)

    // Open the DevTools.
    // mainWindow.webContents.openDevTools()

    // Emitted when the window is closed.
    mainWindow.on('closed', function () {
        // Dereference the window object, usually you would store windows
        // in an array if your app supports multi windows, this is the time
        // when you should delete the corresponding element.
        mainWindow = null
    })
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow)

// Quit when all windows are closed.
app.on('window-all-closed', function () {
    // On macOS it is common for applications and their menu bar
    // to stay active until the user quits explicitly with Cmd + Q
    if (process.platform !== 'darwin') app.quit()
})

app.on('activate', function () {
    // On macOS it's common to re-create a window in the app when the
    // dock icon is clicked and there are no other windows open.
    if (mainWindow === null) createWindow()
})

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

このまま実行すると半透明で小さくてどのウィンドウより前に出るただ?が流れるウィンドウができてるはずです。
SnapCrab_NoName_2019-8-22_0-1-26_No-00.png

ちなみに右クリックすることで閉じたり最大化できます。
SnapCrab_寿司が流れるだけのアプリ_2019-8-21_23-58-10_No-00.png

とってもいらないアプリが完成しました。

electron-builderをいれる

これはyarnをインストールする必要があります。インストーラーに沿ってやればできます。
ちゃんとインストールできたかどうかは以下の文をいれてバージョンが出ればおkです。

yarn -v

そしたら以下の文をいれてelectron-builderをインストールします。

yarn global add electron-builder

package.jsonに書き足す

どっちのpackage.jsonか?srcじゃない方。npm initで作成したほう。
開いてみて明らかに下の中身と違う場合は開くの間違えてます。

package.json
{
  "name": "sushi_portable",
  "version": "1.0.0",
  "description": "寿司の絵文字を眺めるアプリ。",
  "main": "./src/main.js",
  "scripts": {
    "start": "electron ."
  },
  "keywords": [],
  "author": "sushi",
  "license": "ISC",
  "devDependencies": {
    "electron": "^6.0.3"
  }
}

そしてすこし書き足します。buildから増えました。

package.json
{
  "name": "sushi_portable",
  "version": "1.0.0",
  "description": "寿司の絵文字を眺めるアプリ。",
  "main": "./src/main.js",
  "scripts": {
    "start": "electron ."
  },
  "keywords": [],
  "author": "sushi",
  "license": "ISC",
  "devDependencies": {
    "electron": "^6.0.3"
  },
  "build": {
    "productName": "寿司の絵文字眺めるやつ",
    "appId": "sushi.emoji",
    "win": {
      "target": "portable",
      "icon": "./src/icon.ico"
    }
  }
}

productNameが名前、appIdはアプリケーションID(Application User Model ID)らしいです?
targetにはportableにします。これでexeファイルダブルクリックで起動できるアプリになります。ポータブルアプリ。
nsisにすればインストール形式になるそうです?。(要検証)
iconはアイコン画像のパスです。srcの中に入れればいいのですが、拡張子がicoなので画像ファイルを何らかの方法でicoに変換する必要があります。ただし、一つ条件があって画像サイズを256×256にする必要があるようです。

ポータブルアプリ作成

ターミナル(PowerShell・コマンドプロンプト)で以下の文を入力。

electron-builder build --win

あとは終わるまで待ちましょう。
image.png

おわるとdistという名前のフォルダができてるのでその中のにあるexeファイルをダブルクリックして少し待てばウィンドウが出てきます。

完成です!!!

おわりに

exeダブルクリックから数秒~数十秒かかるのは仕様?わからん!

参考にしました。

https://qiita.com/SallyAcolyte/items/94ed26ab62b8b32b1b2c
http://var.blog.jp/archives/78877702.html

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

【JavaScript】配列を引数渡しすると破壊的に動作する話【メモ書き】

まず、このコードを見て欲しい。

const swapAry1 = (ary) => {
  const newAry = [];
  const len = ary.length;
  for (let i = 0; i < len; i++){
    newAry.push(ary.pop());
  }

  return newAry;
} 

a = [1, 2, 3, 4, 5];
console.log('swapAry1');
console.log(a);
console.log(swapAry1(a));
console.log(a);

swapAry1は引数で渡された配列のすべての順番を逆にする関数だ。
swapAry1の実行時、swapAry1ではarypop()により破壊されている。
が、これらを実行したとき、

swapAry1
[ 1, 2, 3, 4, 5 ]
[ 5, 4, 3, 2, 1 ]
[ 1, 2, 3, 4, 5 ]

となるだろうと、思うかもしれない。(つまり、引数で渡した配列aまで破壊されているとは思わないかもしれない。)

しかし、実際には

swapAry1
[ 1, 2, 3, 4, 5 ]
[ 5, 4, 3, 2, 1 ]
[]

となり、引数で渡した配列aまで破壊されている。

どうやら配列を引数にとったとき、「参照渡し」(コメントでご指摘があった通り、『参照の値渡し』や『オブジェクト渡し』が正確な表現のようです。詳細はコメント欄にて。)がされているらしく、仮引数の配列の要素を変更するとそれが実引数の配列にまで影響するようである。参照の値渡しと値渡しについてはJavaScriptで配列のコピー(値渡し) - Qiitaを参照してもらえれば、と。私のこの説明よりわかりやすいと思います。

ついでに、アロー関数以外でもそうなのか試してみました。

const swapAry2 = function(ary){
  const newAry = [];
  const len = ary.length;
  for (let i = 0; i < len; i++){
    newAry.push(ary.pop());
  }

  return newAry;
};

a = [1, 2, 3, 4, 5];
console.log('swapAry2');
console.log(a);
console.log(swapAry2(a));
console.log(a);

function swapAry3(ary){
  const newAry = [];
  const len = ary.length;
  for (let i = 0; i < len; i++){
    newAry.push(ary.pop());
  }

  return newAry;
};

a = [1, 2, 3, 4, 5];
console.log('swapAry3');
console.log(a);
console.log(swapAry3(a));
console.log(a);

実行結果は

swapAry2
[ 1, 2, 3, 4, 5 ]
[ 5, 4, 3, 2, 1 ]
[]
swapAry3
[ 1, 2, 3, 4, 5 ]
[ 5, 4, 3, 2, 1 ]
[]

となりました。ダメです!
破壊的なものが欲しいときにはこれでも問題ないですが。

これを防ぐには、このようにすると良いでしょう。

const swapAry = (initAry) => {
  const ary = initAry.slice();
  const newAry = [];
  const len = ary.length;
  for (let i = 0; i < len; i++){
    newAry.push(ary.pop());
  }

  return newAry;
} 
a = [1, 2, 3, 4, 5];
console.log('swapAry');
console.log(a);
console.log(swapAry(a));
console.log(a);

実行結果

swapAry
[ 1, 2, 3, 4, 5 ]
[ 5, 4, 3, 2, 1 ]
[ 1, 2, 3, 4, 5 ]

この記事は、筆者が配列をシャッフルするアルゴリズムを思いついたが既存だった話【JavaScript/Rubyサンプルコードあり】を書いていたときに気づいたメモです。

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

asciidoctor と JavaScript と Ploy.ly で自習用ノートを作る

概要

AsciiDoc と JavaScript と Ploy.ly の自習用ノートが以外といい感じなので紹介します。
オススメする訳でもなく、こういう用途にも使えますよという話です。

なお、教材は 技術者のための確率統計学 です。

JupyterNotebook 使用してもいいのですが、途中経過を含めた長めの計算式を書くことの方が多かったり、ある程度文書を章立てして管理整理したかったりだとかで、色々あって未採用です。 Sphinx (reStructuredText, Markdown) とかも知っていますが、未採用です。

環境

OS: Ubuntu 18.04
asciidoctor 1.5.8

※ もともと asciidoctor-mathematical が使いたかった故の Ubuntu を使っています。
※ MathJaX など標準機能の範囲でよければ Windows 環境にも構築できるのではないかと思います。
※ (Windowsだと、AsciidoctorJ あたりがパッケージ管理のしやすさ含めて導入しやすいのかなぁ。)

メモの例: トップページ

Screenshot_2019-08-21 技術者のための確率統計学.png

AsciiDoc文書の抜粋

上の画像にある「問1 (p47)」の部分はこのトップページにあたる文書には含んでいません。実体は別ファイルで chapter_1-6-1.adoc 中の question の部分を抜き出して生成しています。(PDF出力を切り捨てればもっとスッキリするだろうけど。)

== Chapter 1 確率空間と確率変数

// === 演習問題 問1 (p47)
ifeval::["{backend}" == "html5"]
include::chapter_1-6-1.adoc[leveloffset=+1,tag=question]

link:chapter_1-6-1.html[演習問題 問1の回答]
endif::[]
ifeval::["{backend}" == "pdf"]
include::chapter_1-6-1.adoc[leveloffset=+1]
endif::[]


=== 演習問題 問2 (p47)

=== 演習問題 問3 (p49)

=== 演習問題 問4 (p49)

=== 演習問題 問5 (p49)

== Chapter 2 離散型の確率分布

メモの例: 本文

Screenshot_2019-08-22 演習問題 問1 (p47).png

AsciiDoc文書の抜粋

JupyterNotebookっぽい使い方ですね。グラフは JavaScriptで書いたものを埋め込んでいます。
KaTeX向けのコードは別のパスにあるものを読み込みます。

(以下のヘッダ部分を除き)全体的に見て分かるかと思いますが、キャプチャーに表示されている内容と同程度の情報量です。
つまり、デザインなど体裁に関わる部分の指定はノート自体には含める必要はなくて、ただ書くことに集中できます。

:stem:

ifeval::["{backend}" == "html5"]
ifdef::use-katex[]
include::{katex-inc-path}[]
endif::[]

ifndef::leveloffset[]
[cols="1d" frame="none" grid="none"]
|===
h| link:index.html[Up]
|===
endif::[]
endif::[]

見出しのページに自動的に引用していた箇所のコード。問1の前の部分は、普通は要らない。

マークアップでよく使うキーワードを定義して、 {foo} みたいな記法で文字を置き換えられるぐらいの話。
「おめが」とか「どっと」って入力して日本語変換候補のリストから選んで文字変換するのはダルイので、
ローマ字入力できるようにしているだけ。

// tag::question[]
== 演習問題 問1 (p47)
:cap-omega: &#937;

:subset: ⊂
:isin: ∈

ifeval::["{backend}" != "pdf"]
:ZZ: ℤ
:cdot: &#8901;
:le: &#8804;
endif::[]

ifeval::["{backend}" == "pdf"]
:ZZ: pass:q[**Z**]
:cdot: ·
:le: ≦
endif::[]

.問1 (p47)
****
1から365の値を均等な割合で出す電子サイコロがある。この電子サイコロを30個集めて、
同時に目を出したときに得られる、30個の値の組み合わせを根源事象とする標本空間{cap-omega}を考える。
30個の値がすべて異なる根源事象を集めた部分集合 _A_ {subset} {cap-omega} とするとき、確率 _P_(_A_)は、
およそ30%となることを示せ (これは、30人のクラスで、全員の誕生日が異なる確率の近似的な
計算と考えられる。最終的な数値計算は、コンピュータープログラム等を用いても構わない)。
****

// end::question[]

回答本文。テーブルやら、コードハイライトやら、JavaScriptのグラフ埋め込みやらがあります。

  • AsciiDocだと、数式は AsciiMath でも書くことができます。
    AsciiMathは、使い方は知っていますが、好みではないので使っていません。
    板書をリアルタイムにノートを取るとかだと、 AsciiMath の方がよいのかもしれませんね。
  • 添え字付き文字 Ai だとかは、AsciiDoc記法で書いています。
    Markdown だと A<sub>i</sub> とかだるいですね。
    LaTeXだと情報量が多くなるとページのリロードに時間がかかるので、良く使うマークアップが簡易に書けるのは良い事です。
=== Solution 1
標本空間は {cap-omega} {isin} ++{++ (_z_~1~,...,_z_~30~) | _z_~k~ {isin} {ZZ}, 1 {le} _z_~k~ {le} 365 ++}++ となるから、
{vbar}{cap-omega}{vbar}=365^30^ となる。{vbar}__A__{vbar}= (365-0){cdot}(365-1){cdot}...{cdot}(365-29) となる。

[stem,latexmath]
++++
P(A) = \frac{365 \cdot 364 \cdots 336}{365^{30}}
++++

=== Solution 2
[cols="1a,8a"]
|===
| A~1~ | 1個目の電子サイコロの目が0個目の電子サイコロと異なる事象。
| A~2~ | 2個目の電子サイコロの目が1個目の電子サイコロの目と異なる事象。
| A~3~ | 3個目の電子サイコロの目が1、2個目の電子サイコロの目と異なる事象。
| ... |
| A~29~ | 29個目の電子サイコロの目が1~28の電子サイコロの目と異なる事象。
| A~30~ | 30個目の電子サイコロの目が1~29の電子サイコロの目と異なる事象。
|===

[stem,latexmath]
++++
\begin{aligned}
P(A_{30})
&= P(A_{29})\cdot\dfrac{365-29}{365} \\
&= P(A_{28})\cdot\dfrac{365-28}{365}\cdot\dfrac{365-29}{365} \\
&= \cdots \\
&= P(A_{1})\cdot\dfrac{365-1}{365}\cdots\dfrac{365-28}{365}\cdot\dfrac{365-29}{365} \\
&= \dfrac{365}{365}\cdot\dfrac{365-1}{365}\cdots\dfrac{365-28}{365}\cdot\dfrac{365-29}{365} \\
\end{aligned}
++++

[source,javascript]
----
let sum=1.
for (let i = 0; i < 30; i++) {
    let numerator = 365 - i
    let denominator = 365
    sum *= numerator/denominator
    console.log('%d / %d -> %f', numerator, denominator, sum)
}
----

[source,console]
----
$ node unique_birthday.js
365 / 365 -> 1
364 / 365 -> 0.9972602739726028
363 / 365 -> 0.9917958341152187

...snip...

337 / 365 -> 0.3190314625222229
336 / 365 -> 0.2936837572807312
$
----

ifeval::["{backend}" == "html5"]
[pass]
++++
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<div id="myDiv"><!-- Plotly chart will be drawn inside this DIV --></div>
<script>
  let ary_x = [];
  let ary_y = [];
  let sum=1.;
  for (let i = 1; i <= 60; i++) {
      let numerator = 365 - (i - 1)
      let denominator = 365
      sum *= numerator/denominator
      ary_x.push(i);
      ary_y.push(sum);
  }

  var trace1 = {
    x: ary_x,
    y: ary_y,
    type: 'scatter'
  };

  var data = [trace1];

  Plotly.newPlot('myDiv', data);
</script>
++++
endif::[]

参考

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