メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

スマホのキャプチャを扱いやすくするCLI「pbssc」を作った

久しぶりの更新です。

コードレビューや動作確認などでスマホ画面のキャプチャを貼り付けたくなることがあります。

そういう時に、シュッとキャプチャをとってClipboardに貼り付けられるツールを作りました。

github.com

インストールはHomebrew経由で行えます。

$ brew tap sakebook/tap && brew install pbssc

オプションをつけてOSを指定して実行できます。

// Android
$ pbssc -a

// iOS
$ pbssc -i

READMEにdemo動画があります。

実装のコアは既存ツールに依存

実際にキャプチャを撮る部分はadblibimobiledeviceに依存しています。

adb、つまりAndroid端末だと次のようにキャプチャを取れます。

$ adb exec-out screencap -p > example.png

libimobiledevice、つまりiOS端末だと次のようになります。

$ idevicescreenshot example.png

キャプチャを撮るだけであればそれぞれのツールで既にサポートされていたので問題ないのですが、今回特に解決したかったのは Clipboardへの貼り付けでした。

Clipboardへの貼り付け

pbcopy が使えるかと思ったのですが、 画像pathからは渡すことができませんでした。

そこで今回使ったのは AppleScript です。

AppleScript

AppleScript is a scripting language created by Apple. It allows users to directly control scriptable Macintosh applications, as well as parts of macOS itself. You can create scripts—sets of written instructions—to automate repetitive tasks, combine features from multiple scriptable applications, and create complex workflows.

DeepL翻訳

AppleScriptは、アップル社が開発したスクリプト言語です。ユーザーは、スクリプト可能なMacintoshアプリケーションやmacOS自体の一部を直接制御することができます。スクリプト(一連の命令文)を作成することで、反復的なタスクを自動化したり、複数のスクリプト可能なアプリケーションの機能を組み合わせたり、複雑なワークフローを作成したりすることができます。

developer.apple.com

試しにどんなものか確認したい場合は、手元で次のコマンドを実行するとmacOSのアラートを表示することができます。

$ osascript -e 'display alert "Hello World"'

AppleScriptを使えば、macOS上の.appなども起動したりできます。cronで動かすことで日々のルーチンの自動化などもできそうですね。

今回はAppleScriptを使ってClipboardを操作しました。

既存ツールとAppleScriptを混ぜる

ShellScriptからAppleScriptを呼び出すことも、その逆も可能でしたが、今回はShellScriptからAppleScriptを呼び出すことにしました。既存ツールの存在確認などがやりやすいと思ったからです。

AppleScriptを実行するときは osascript コマンドを使います。なので、ShellScript内では次のように記述してAppleScriptを呼び出します。

copy_to_clipboard () {
   osascript -e "
   on run
     set the clipboard to POSIX file \"$filepath\"
   end
  "
  echo "Set image to clipboard from $filepath"
}

関数にすることは必須ではないです。

これらをまとめて取り扱いやすくしたのがpbsscです。

まとめ

同じような課題感を持っている人に対して便利なツールになったと思います。

直接使わなくても、今回紹介した内容が間接的にでも助けになれば良いなと思います。

参考

Android Debug Bridge (adb)  |  Android Developers

libimobiledevice · A cross-platform FOSS library written in C to communicate with iOS devices natively.

macos - Set clipboard to image - pbcopy - Stack Overflow

Commands Reference

2020年を振り返って

目標とか

英語

年始は本を読んだり洋書(絵本)に手を出してたのですが、わりと早い段階で途切れてしまっていました。しかしTwitterで知り合いが英語をコツコツ継続して勉強してるのを見て、自分もやらねば!と復活しました。

Duolingoというアプリで英語を学んでいます。割と破壊的な変更が予告なく行われる点はうーむといった感じなのですが、自分のスタイルとあっているみたいで継続はできています。

ただ、学んでて感じるのは単語力が必要なのと、リスニング、作文がDuolingoだと学ぶのが難しいです。

このあたりは実際にリスニングや作文をしないと身につかないので、最近はHelloTalkというアプリを教わったので使ってます。

きちんと学んでいる人とつながれば、ネイティブスピーカーと会話できるので良いなと感じています。また、日本語を学んでいる外国人の投稿も見れるので、お互い得意な言語で文章を修正し合ったり、そういうところを疑問に思うんだなというのが見え、気づきがあって面白いです。

リングフィットアドベンチャー

今年といえばコロナがあり、尚の事外に出る機会も減り、運動が必要になる年でした。

しかし、部屋のレイアウトを変更してから全くできていません。こちらは習慣が切れてしまったのでなんとか巻き直す必要があります。

同僚が毎日リングフィットを継続できているみたいで、そこから刺激をもらってやっていきたい。

Flutter

全然触れてなかったです。Kotlin/JSで小さく動くものを作ってばかりだった気がします。

この1年でFlutterの事例なども無数に増えて、そこまで珍しいものでは無くなっている感じがします。

使われて当たり前の技術になっていくのであれば、尚の事抑えたいのでこちらも継続して取り組んでいきたいことには変わらないです。

やったこと

Kotlin/JS

いくつか動くものを作りました。

オウム返しするだけのLINE Bot

sakebook.hatenablog.com

任意のURLのキャプチャをSlackに投稿してくれるやつ

sakebook.hatenablog.com

Server Side Kotlin

業務でKtor + Firebase + Cloud Runでサーバレスな通知基盤とCRUDAPIを用意したりしました。

普段あまりサーバに触れないので経験としてよかったです。

Kotlin/JSでサブシステムも作ったりはしていたのですが、本運用はしていません。

シニアエンジニアへの道

会社で求められることも少し変わったりしました。今はスクラムマスター的なことにも取り組んでいます。 社内で明確に次のステップが用意されてるのはありがたく思います。

Contribute

Exposure Notifications APIが公開され、動かしてコードを見てたところ、少し直せそうなところがあったので最速でPR作りました。

しかし内部で修正され、マージするに至りませんでした。

github.com

趣味とかその他

Minecraftにハマった

名前は聞いたことあったのですが、やったことはなかったです。

会社の人と始めたのですが、自分はドハマリしてかなりやってました。

エンダードラゴンを倒してエリトラを入手したのですが、帰路で全ロスしその後エンチャントを付与したダイヤ装備を2連続で全ロスして心が折れました。

傷が癒えた頃に再びやりだして、ダイヤ装備と厳選したエンチャント、エメラルドを安定して入手できる仕組みを作るまではやりましたがそこで止まっています。

家の環境整備

リモートワークがメインになったので、家の環境を整えました。

昇降デスクは2020年のベストバイでした。

sakebook.hatenablog.com

コーヒーを飲む機会も増え、いろんな抽出機材を買ったりしましたが今はまたハンドドリップに落ち着いています。自家焙煎にチャレンジしたい。

また、年内には届かなかったのですが良い椅子も買いました。多分2020年で一番高い買い物でした。

お菓子みたいな、つまむものを置いておく場所が家になかったので、スツールを買ってみたのですが思った以上に具合が良かったので追加でもう一つ買ってしまいました。

一つはお菓子BOXでソファーのオットマンとして利用し、もう一つは飲料水などを入れて玄関でちょっと腰掛けられる場所として利用してます。

自分が購入したのはこちらですが、サイズやデザインが若干違うものが無数にあるので気になったものが見つかればそれを選ぶと良いと思います。

完全栄養食

朝はCOMP DRINKにして、昼はガッツリ食べて夜は日中お菓子を食べているので抜きにするか追加でお菓子を食べるという生活をしていたのですが、体重がかなり落ちました。

お菓子も、ポテチかナッツというのが多かったのですがジャンキーなのは体に良くないと考え、ポテチはBASE BREADに差し替えました。

以前試したときは、冷凍でかつ小分けになっていなかったのでやめたのですが、今はそれらの問題が解決され、味も増えているので継続できそうです。

夜飯を完全に置き換えるというよりは、罪悪感のないお菓子くらいな感覚で利用しています。

こう書くとストイックそうに見えますが、土日は好きなものだけ食べたり、お酒の席では暴飲暴食をしています。

本を読む

本を読む習慣をつけたいと思い、家だとその切り替えがなかなかできないため、特別感を出すためにカフェで読むことが多いです。

カフェ自体は行くのであまり特別感はないのですが、そこで世界のビールを飲むことで特別感を出しています。

金曜の夜に敢えてカフェに行くことで、三密を避けることができています。

個人的には好きな時間になってるのでなるべく継続したいなと思っています。

ボドゲ

今年はやる機会も大変少なく、あまり購入していません。

2021年

2020年にやりたいと思ってたことは継続してやりたいことなので引き続きやっていきます。

定量的に見る場合、

  • 英語はDuolingoで全スキルを5にする
  • リングフィットはストーリーは終わらせたいのと習慣化(週1とか?)できているかどうか
  • Flutterは何らかのアプリを作る

とかですかね。

あと、漠然としてますが何か新しいこともやりたいですね。やりたいというより、やっておかないと諸々まずいなという漠然とした焦燥感かもしれません。

Kotlin/JSでHeadless Chromeを使って画面をキャプチャしてSlackに投稿する

ちょっと利用したくなったのでKotlin 1.4のキャッチアップも兼ねて作りました。

環境は

  • Kotlin 1.4.21
  • dukat 0.5.8-rc.3
  • Node 12

です

こんな感じで任意のURLのキャプチャがSlackに投稿されるものです。

f:id:sakebook:20201220231715p:plain

前回触った記事はこちら

sakebook.hatenablog.com

kotlin 1.4でどう変わった

KotlinのJSの実装は package_imported/ というフォルダに展開されていました。しかしプロジェクト の package.json にその参照はされていませんでした。なので、解決のためにはnpm registryから取得してきていました。

しかし、1.4から package.json に、ローカルpathが参照されるようになりました(file:/XXX)。なので、以前のように外部から無理やり依存関係を解決しなくても良くなりました。

ただしこれはこれでCloud Functiionsへのデプロイ時に一部修正が必要です(後述)。

1.4以前

{
  "main": "kotlin/kotlin-js-cloud-function-linebot.js",
  "devDependencies": {
    "source-map-support": "0.5.16"
  },
  "dependencies": {
    "kotlin": "1.3.72",
    "kotlinx-serialization-kotlinx-serialization-runtime": "0.20.0",
    "@jetbrains/kotlin-extensions": "^1.0.1-pre.91",
    "axios": "^0.19.2"
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": [],
  "name": "kotlin-js-cloud-function-linebot",
  "version": "1.0.0-SNAPSHOT"
}

1.4以後

{
  "main": "kotlin/kotlin-js-headless-chrome.js",
  "devDependencies": {
    "dukat": "0.5.8-rc.3",
    "source-map-support": "0.5.19"
  },
  "dependencies": {
    "axios": "0.21.0",
    "puppeteer": "5.5.0",
    "form-data": "3.0.0",
    "@google-cloud/secret-manager": "3.2.2",
    "kotlin": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin/1.4.21",
    "kotlinx-coroutines-core": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-coroutines-core/1.3.9",
    "kotlinx-atomicfu": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-atomicfu/0.14.4",
    "kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC",
    "17def1782b5ee417-kotlinx-nodejs-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7",
    "kotlin-test-js-runner": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test-js-runner/1.4.21",
    "kotlin-test": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test/1.4.21"
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": [],
  "name": "kotlin-js-headless-chrome",
  "version": "0.0.1"
}

dukatはKotlin/JSのpluginに統合され、利用しやすくなりました。具体的には、次のように引数で指定してあげることで generateExternalsIntegrated タスクが実行され、ソースコードを生成してくれます。

implementation(npm("axios", "0.21.0", generateExternals = true))

なので package.json のdevDependenciesに含まれているわけです。

また、CoroutineやSerializationも同様に利用しやすくなっています。

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC")

採用したのはPuppeteer

Cloud FunctionsでPuppeteerが利用可能になったのは知っていたので、同様にHeadless Browserを扱えるPlaywrightを利用しようとしたのですが、結論うまくいきませんでした。

github.com

github.com

デプロイ時にchromiumを含めると、ファイルサイズの制約でデプロイに失敗します。なので、デプロイ後のmain関数実行時にchromiumをDLする流れを取っているPuppeteerを採用しました。

関数実行時にchromiumをDLする流れ

Playwrightでも環境変数を指定してあげれば同じ流れになるはずなのですがうまく行っていません。issueは上げたのでどこかのバージョンで解決されると思います。

ちなみにどうしてもPlaywrightを使いたい場合、playwright-aws-lambdaを利用すれば可能です。こちらはchromiumを含んでいますが、圧縮することでファイルサイズの制約を回避しています。

github.com

Cloud Functionsへのデプロイ

Cloud Functionsへのデプロイは、今回はCloud Buildで行いました。

Kotlin/JSをCloud Functionsで動かすには次の3パターンの方法が考えられます。

  • すべて込みのjsファイルを作る
  • package-lock.json込みであげて依存関係を解決する
  • package.json込みでdeploy時に実行してもらう

すべて込みのjsファイルを作る

こちらはひとつのjsファイルにすべての実装を詰め込んだものを用意して動かすパターンです。あまり筋が良くないので却下しました。

package-lock.json込みであげて依存関係を解決する

こちらは node_modules/ をデプロイ時のソースコードに含むものです。前述の通り、chromiumを含むことになるのでファイルサイズの問題で却下しました。

package.json込みでdeploy時に実行してもらう

なので消去法でこちらの方法でデプロイします。

Cloud Functionsデプロイのために、タスクを定義します。

  • build.gradle.kts
tasks {
    val packaging by creating(Copy::class) {
        val directory = "functions"
        from("build/js/packages/${project.name}/kotlin/${project.name}.js", "build/js/", "build/js/packages/${project.name}/package.json")
        exclude("**/packages/", "node_modules/", "node_modules.state", "yarn.lock")
        into("$directory/")
        rename { it.replace("${project.name}.js", "index.js") }

        doLast {
            val jsonFile = file("$directory/package.json")
            val texts = jsonFile.readLines()
                .map { it.replace("kotlin/${project.name}.js", "index.js") }
                .map { it.replace("file:${project.projectDir.absolutePath}/build/js/", "") }
            jsonFile.writeText(texts.joinToString("\n"))
        }
    }
}

Kotlin関連の実装はローカルの絶対pathを参照しているので、このままだと環境によって差分が出てしまいます。それを埋めるために、相対pathを参照するようにし、デプロイ時に必要なものだけをまとめるディレクトリを生成しています。

これによってfunctionsフォルダが生成され、次の構成になります。

├── functions
│   ├── index.js
│   ├── package.json
│   └── packages_imported
│       ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy
│       │   └── 0.0.7
│       │       ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js
│       │       ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js.map
│       │       └── package.json
│       ├── kotlin
│       │   └── 1.4.21
│       │       ├── kotlin.js
│       │       ├── kotlin.js.map
│       │       └── package.json
│       ├── kotlin-test
│       │   └── 1.4.21
│       │       ├── kotlin-test.js
│       │       ├── kotlin-test.js.map
│       │       └── package.json
│       ├── kotlin-test-js-runner
│       │   └── 1.4.21
│       │       ├── karma-debug-framework.js
│       │       ├── karma-debug-runner.js
│       │       ├── karma-kotlin-reporter.js
│       │       ├── kotlin-test-karma-runner.js
│       │       ├── kotlin-test-karma-runner.js.map
│       │       ├── kotlin-test-nodejs-runner.js
│       │       ├── kotlin-test-nodejs-runner.js.map
│       │       ├── mocha-kotlin-reporter.js
│       │       ├── package.json
│       │       ├── tc-log-appender.js
│       │       └── tc-log-error-webpack.js
│       ├── kotlinx-atomicfu
│       │   └── 0.14.4
│       │       ├── kotlinx-atomicfu.js
│       │       ├── kotlinx-atomicfu.js.map
│       │       └── package.json
│       ├── kotlinx-coroutines-core
│       │   └── 1.3.9
│       │       ├── kotlinx-coroutines-core.js
│       │       ├── kotlinx-coroutines-core.js.map
│       │       └── package.json
│       └── kotlinx-serialization-kotlinx-serialization-core-jsLegacy
│           └── 1.0.0-RC
│               ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js
│               ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js.map
│               └── package.json

functionsディレクトリには、ローカルで参照するKotlin関連の実装と、Cloud Functionsの規則に合わせたindex.jsというデプロイしたい関数の実装ファイルと、package.jsonがあります。

package.jsonは次のようになります。

{
  "main": "index.js",
  "devDependencies": {
    "dukat": "0.5.8-rc.3"
  },
  "dependencies": {
    "axios": "0.21.0",
    "puppeteer": "5.5.0",
    "form-data": "3.0.0",
    "@google-cloud/secret-manager": "3.2.2",
    "kotlin": "packages_imported/kotlin/1.4.21",
    "kotlinx-coroutines-core": "packages_imported/kotlinx-coroutines-core/1.3.9",
    "kotlinx-atomicfu": "packages_imported/kotlinx-atomicfu/0.14.4",
    "kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC",
    "17def1782b5ee417-kotlinx-nodejs-jsLegacy": "packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7",
    "kotlin-test-js-runner": "packages_imported/kotlin-test-js-runner/1.4.21",
    "kotlin-test": "packages_imported/kotlin-test/1.4.21"
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": [],
  "name": "kotlin-js-headless-chrome",
  "version": "0.0.1"
}

mainが変わり、絶対pathだった部分が相対pathになっています。

デプロイについてですが、cloudbuild.yamlは次のようになっています。

steps:
  - name: gradle:6.3-jdk8
    entrypoint: gradle
    args: ['cKJ', 'packaging']
  - name: 'gcr.io/cloud-builders/gcloud'
    dir: 'functions/'
    args:
      - functions
      - deploy
      - capture
      - --source=.
      - --region=asia-northeast1
      - --trigger-http
      - --runtime=nodejs12
      - --allow-unauthenticated
      - --memory
      - 512MB
      - --service-account=secret-manager@able-source-341.iam.gserviceaccount.com
      - --update-env-vars
      - PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token

Gradleが実行可能なDockerコンテナでjsファイルの生成とデプロイの準備をし、gcloudコマンドが利用可能なDockerコンテナでCloud Functionsのデプロイコマンドを実行します。

不要なファイルをアップロードしないように dir で作業ディレクトリを移動しています。

Secret Managerの利用

Cloud Functionsで環境変数に機密性の高い値を置くのは不適切とされています。

cloud.google.com

YAML ファイル、デプロイ スクリプト、ソース管理に重要な認証情報は保存しないでください。

前回は環境変数に置いてしまっていたので、今回はSecret Managerで管理する方法を取りました。

cloud.google.com

dukatでそのまま生成はできなかったので、次のようなファイルを用意してKotlinから利用しました。

  • SecretManager.kt
@JsModule("@google-cloud/secret-manager")
external object SecretManager {
    class SecretManagerServiceClient {
        fun accessSecretVersion(json: kotlin.js.Json = definedExternally): Promise<Array<AccessSecretVersionResponse>>
    }
}

@Serializable
external class AccessSecretVersionResponse {
    @SerialName("name")
    val name: String
    val payload: Data
}

@JsName("data")
external class Data {
    val data: Buffer
}

external class Buffer {
    fun toString(str: String): String
}

fun Array<AccessSecretVersionResponse>.value(): String {
    return this[0].payload.data.toString("utf8")
}

利用するには

  • Secret Manager のシークレット アクセサーのロール
  • PROJECT_ID
  • 保存しているSecretのKey名

が必要になります。Cloud Buildでは次の部分がそれに当たります。

      - --service-account=secret-manager@able-source-341.iam.gserviceaccount.com
      - --update-env-vars
      - PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token

Cloud Functionsのランタイムサービスアカウントは、デフォルトだと PROJECT_ID@appspot.gserviceaccount.com となっています。なので、そのサービスアカウントにSecret Manager のシークレット アクセサーのロールを与えるか、Secret Manager のシークレット アクセサーのロールを持ったサービスアカウントを別途用意する必要があります。今回は後者のほうで実現しています。

cloud.google.com

ロールの設定

少し複雑ですが、

  • Cloud Buildを実行するメンバー
  • Cloud Functionsを呼び出すメンバー
  • Cloud Functionsを実行するメンバー

はそれぞれ別です。

Cloud Buildを実行するメンバーはデフォルトだと PROJECT_NUMBER@cloudbuild.gserviceaccount.com というサービスアカウントです。Cloud Functionsのリソースにアクセスするので Cloud Functions 開発者 のロールを追加しておく必要があります。

cloud.google.com

Cloud Functionsを呼び出すメンバーには、Cloud Functions 起動元のロールが必要です。publicにしたい場合は allUsers メンバーにCloud Functions 起動元のロールを追加してあげると良いです。

今回は定期実行させたかったので、 Cloud Schedulerから呼び出すことにしました。

cloud.google.com

AuthヘッダーにOIDCトークンを選択し、Cloud Functions 起動元のロールをつけたサービスアカウントを設定することで安全に呼び出しています。

Cloud Functionsを実行するメンバーは、 前項で設定しているサービスアカウントです。

注意が必要なのは、Cloud Functionsを呼び出すメンバーと実行するメンバーが異なるということです。呼び出すメンバーにSecret Manager のシークレット アクセサーのロールは不要です。逆に持っていたとしても、実行するメンバーにそのロールが与えられていなければ読み取りエラーになります。

ファイルの保存

今回はSlackに投稿するに当たり、一旦キャプチャした画像をファイルとして保存してから投稿するようにしました。 Cloud Functionsでは、/tmp ディレクトリ以外は書き込み権限がありません。ローカルでも同様に確認できるようにするため、os.tmpdirで保存先を確認するようにしています。

cloud.google.com

form-dataでファイルを投稿する

ハマってしまったのですが、Node.jsからファイルを送る場合form-data形式で送る必要があります。Kotlin/JSでもFormDataは用意されてるのですが、こちらはブラウザでの利用を想定されているものでNode.jsから動かした場合正しく機能しません。 form-dataを依存関係に追加して利用する必要があります。

www.npmjs.com

Kotlinから利用するに当たり、最低限次のように定義しました。

  • FormData.kt
@JsModule("form-data")
external class FormData {
    fun getBoundary(): String
    fun append(string: String, dynamic: dynamic)
}

まとめ

ローカルで動かすだけなら比較的すんなりいくのですが、Cloud Functionsで動かそうとすると考えることがとても増えて苦しみました。 IAM周りの話や、Headless Chromeを扱うようなときは参考にできると思います。

リポジトリはこちらです。

github.com

参考

品質とパフォーマンスに注力した Kotlin 1.4 をリリースしました | JetBrains Blog

Specifying dependencies in Node.js  |  Cloud Functions Documentation

Quotas and limits  |  Firebase

https://rominirani.com/using-puppeteer-in-google-cloud-functions-809a14856e14

playwright/installation.md at master · microsoft/playwright · GitHub

Secret Managerを使ってみる@Cloud Functions編 | apps-gcp.com

Slack

javascript - Google Clould Functions deploy: EROFS: read-only file system - Stack Overflow

Node.js上からmultipart/form-data形式でHTTPリクエストをする - Qiita