詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Fork me on GitHub

Puppeteerでファイルをダウンロードする2022

Puppeteer ってソラで書けますか? ぼくは書けないので pptr.dev にアクセスしてコピペしてます。

Puppeetteer でファイルをダウンロードする方法はわかりやすい API としては提供されておらず、Stackoverflow を毎回見てる。これも古い方法が出回ったままだったりするので令和4年現在での最新版と思われる方法を書いておく。

例として、https://motemen.github.io/beautiful-graph-maker/ から画像をダウンロードしてみる。これは JavaScript で画像を生成してるのでブラウザのインスタンスが必要なやつだ。

Chrome DevTools Protocol 経由でダウンロードする

Chrome DevTools Protocol ってのは Chrome その他のブラウザをプログラムから操作・計測・デバッグ等々するためのプロトコルらしい。Pptr からこれにアクセスする API があるので、こちらを経由する。

const cdpSession = await page.target().createCDPSession();

CDP 経由で Browser.setDownloadBehavior を呼ぶ。世間的には Page.setDownloadBehavior を呼ぶ、というサンプルが多いようだけど、これは deprecated らしいので今はこちら。

await cdpSession.send("Browser.setDownloadBehavior", {
  behavior: "allow",
  downloadPath,
  eventsEnabled: true,
});

ダウンロード完了を知りたいので eventsEnabled を true にしておく。behavior については後述。

イベントが有効になったので Browser.downloadProgress イベントをリスンする。これでダウンロード完了のタイミングがわかる。

const downloaded = new Promise<void>((resolve, reject) => {
  cdpSession.on(
    "Browser.downloadProgress",
    (params: { state: "inProgress" | "completed" | "canceled" }) => {
      if (params.state == "completed") {
        resolve();
      } else if (params.state == "canceled") {
        reject("download cancelled");
      }
    }
  );
});

あとは Promise の使い方という感じで、適当にタイムアウトを設定しつつダウンロードする。ちなみにダウンロードされた正確なファイル名は分からないので、ダウンロード先のディレクトリは毎回新しいものを作るようにするのがよさそう。

Browser.setDownloadBehavior に behavior: "allowAndName" を指定するとファイル名が Browser.downloadProgress で取得される guid になるようだけど、この場合拡張子が消えるのに注意。Browser.downloadWillBegin イベントで suggestedFilename を取得しておくといいだろう。

await button.click(); // これでダウンロード開始

await Promise.race([
  downloaded,
  new Promise<boolean>((_resolve, reject) => {
    setTimeout(() => {
      reject("download timed out");
    }, DOWNLOAD_TIMEOUT);
  }),
]);

Playwright だと

ちなみに Puppeteer と同様にブラウザをプログラムから扱う microsoft/playwright というライブラリはつづりが分かりやすいだけでなく、ダウンロード用の API も提供してるらしい。これだけでいけた。

const [download] = await Promise.all([
  page.waitForEvent("download"),
  button.click(),
]);

// {
//   path: '/var/folders/hm/0xt2zy.../279506cd-00e1-43d5-ad4f-f72909ba706c',
//   suggestedFilename: 'graph.png'
// }
console.log({
  path: await download.path(),
  suggestedFilename: download.suggestedFilename(),
});

path は guid 的なものになるようなので、suggestedFilename から拡張子を取得する。

今回使ったコードの全体は Gist にある: https://gist.github.com/motemen/ce4ca5d4134b31a11725f3461a09c136

Makefileの代わりにnpm scripts+zxを使う

そこそこの規模があるプロジェクトで実行すべきタスクを定義するとき、初手として Makefile を使いがち。

  • Pros
    • make は事実上どんな環境にもあることを期待してよい
    • シェルで実行されるコマンドをそのまま書ける
    • タスクの依存関係が明示できる
  • Cons
    • make では positional arguments が使えない
    • 少し複雑なことをしようとすると Makefile 専用の文法を覚える必要がある
    • 現代では、ファイルベースのタスクの依存関係は make が発明されたころほどは必要ではない
      • Docker とか Go とか Webpack がよしなにしてくれることが多い

例: docker compose のラッパー

ちょっとしたコマンドのラッパーを書きたいことがある。Makefile を書きはじめたらすべてのエントリポイントを make にしたい。ということで、以下のような Makefile を発明していた。docker compose のラッパーで、ステージを指定すると docker-compose.*.yaml も読み込んでくれるようなやつだ。

が、make compose STAGE=test COMPOSE_COMMAND='run --rm app go test' と名前付きパラメタとして渡さなきゃいけないのが面倒。

# Makefile
COMPOSE_COMMAND =
DOCKER_COMPOSE_BIN = docker compose
DOCKER_COMPOSE = $(if $(STAGE),$(DOCKER_COMPOSE_BIN) -f docker-compose.yaml -f docker-compose.$(STAGE).yaml,$(DOCKER_COMPOSE_BIN))

.PHONY: compose
compose:
     $(DOCKER_COMPOSE) $(COMPOSE_COMMAND)

google/zx を使う

そもそも make への引数を渡しかた自体あまり自明じゃない気もするし、そろそろ別のものにしてもいいなってことで代替を考える。最近は JavaScript(Node)がユニバーサルなのでまあこれでいいよね。個人的には JS がコードベースに含まれないプロジェクトでもビルドスクリプトのために Node を導入してもいいと思ってる。

代替として make の利点、「シェルコマンドをそのまま書ける」を潰さないように google/zx を使うことにする。シェルスクリプトっぽく JS を書くためのライブラリ集とラッパーコマンドという感じ。

GitHub - google/zx: A tool for writing better scripts

これのインストールのために npm/yarn を使うわけなので、エントリポイントは npm scripts にしてしまおう。自動的に positional arguments も使えることになる。

複雑なものは scripts/ 以下に

package.json に複雑なコマンドを書くことは不可能なので、リポジトリの scripts ディレクトリなどに zx 向けのコマンドを置くことになる。愚直に書くならこんな感じかな。$ でコマンドを実行できたり、トップレベルで await が使えたり、argv が最初から提供されていたりと書きやすい。

#!/usr/bin/env -S npx zx
// vim: set ft=javascript:

const docker_compose =
  argv.stage
    ? [
        "docker",
        "compose",
        "-f",
        "docker-compose.yaml",
        "-f",
        `docker-compose.${argv.stage}.yaml`,
      ]
    : ["docker", "compose"];

await $`${docker_compose} ${argv._.slice(1)}`;

(ちなみにこれはほんとうに初期のバージョンで、docker compose に渡したいフラグが minimist に食われないようにしたり、TTY が必要なコマンドを実行したり、みたいなことを考えると zx ではなく普通に node のスクリプトを書くことになる……。)

これを package.json に書いちゃえばいい。

{
  "scripts": {
    "compose": "zx ./scripts/compose"
  }
}
% yarn compose --stage=test ps
$ zx ./scripts/compose --stage=test ps # yarn の出力
$ docker compose -f docker-compose.yaml -f docker-compose.test.yaml ps # zx の出力
...

ちなみに #!/usr/bin/env -S npx zx でファイルを直接起動することもできるようになる。

細かいのはひとつのファイルにまとめる

まあこれでやりたいことは満たせるわけだけど、シェルスクリプトで2行くらいのものをひとつひとつファイルにするものナー。そういう場合はひとつのファイルにまとめてしまえばいい。zx にはなぜか Markdown に書かれたスクリプトを実行する機能があるので、scripts/README.md などにこんなことを書いてしまう。

# scripts

いろんなタスクをまとめたファイルだよ~

```javascript
const tasks = {};
```

## test

テストを実行するよ~

```javascript
tasks["test"] = async () => {
  await $`go test ./...`;
}
```

## おわりに

```javascript
const task = tasks[argv._[1]];
if (!task) {
  // タスクがなかったら一覧を表示してやる
  await $`cat ${__filename} | sed 's/^## //p;d'`;
} else {
  await task();
}
```

こんなコードを書いとくと、zx ./scripts/README.md test などでテストが実行できてしまう。package.json にはコメントを書きづらいので、ここに説明が書けるのも便利だ。

Scrapboxのコマンドラインクライアントを作った

ちょっとした手元の自動化を行おうとしたときに、Scrapbox のページ一覧を取得したり、プロジェクトの情報を取得したりしたくなる。Scrapbox ではとにかくほぼすべてが わかりやすい JSON API 経由で行われているわけなので、これをしてくれるコマンドラインツールがほしいわけです。

GitHub - motemen/sbx: An unofficial Scrapbox client

ちなみに完全に野良だし、API も内部 API とされていて安定はしていないはず。そういうものです。

使い方

API の薄いラッパーなので基本的に JSON を返す。--jq オプションで結果を gojq で編集できる。

% sbx page list help-jp --limit 5 --jq 'map(.title)'
[
  "Scrapboxの使い方",
  "ブラケティング",
  "ページをリンクする",
  "セキュリティポリシー",
  "Googleアカウント以外でのログイン"
]
% sbx api --session s:... api/projects --jq '.projects[] | select(.plan == null) | {displayName, name}'
{
  "displayName": "Googleの広告設定を共有してメンバー間でつながるプロジェクト",
  "name": "adsettings-google-com"
}
{
  "displayName": "Saijiki",
  "name": "saijiki"
}

設定

非公開のプロジェクトから情報を得るには、リクエストにセッション(connect.sid) クッキーを食わせてやる必要がある。--session コマンドライン引数でも与えられるけど、~/.config/sbx/config.json に設定を書いておくと、自動的にそこから値を取ってくる。

固定値を書くのであれば "value" に、コマンドから取りたいなら "command" に設定するとよい(たとえば "command": "envchain scrapbox sh -c '/bin/echo -n $SCRAPBOX_SESSION'" とか)。

{
  "projects": {
    "<project>": {
      "session": {
        "command": "<command to print session>", // or
        "value": "<constant value>"
      }
    }
  },

  // default value
  "default": {
    "session": ...
  }
}

JSON権の保障

巷にはこの手の野良ツールがたくさんあるようだけど、どれも(そりゃそうなのだが)作者のニーズを満たすために作られているので、自分のような他人が使おうとすると帯に短したすきに長し……ということが多い。CLI に限らず API クライアントを作る際には、ツールの方はあまり賢くしすぎず、できるだけ加工しないデータをそのまま渡し、JSON の色付けのようなことはユーザに任せる、とするのがいいと自分では思っている。

コマンドラインツールを作る文脈では、近年では itchyny/gojq の存在によりこの提供がグッとしやすくなっていて、プログラム自身に jq を埋め込めるので OS 標準でもない jq が手元にインストールされていることを強要するような居心地の悪さと決別できるようになっている。gh がこれをやってくれたおかげで心理的にもやりやすくなった感がある。

ちなみに内部では Goで知らないフィールドのあるJSONを取り扱う - 詩と創作・思索のひろば で書いたパターンを利用してます。

はてなで一緒に働きませんか?