Puppeteerでウェブのスクショを連続取得しSlackに投稿する
社内でとあるページのスクショをFireshotで取得して、Slackのチャンネルに特定の人にメンション付きで投稿という作業を毎日30件近く行っている。時間も労力も勿体無いなぁと思い、Excelに記録したURLをもとにPuppeteerでスクショを取り、メンション付きで、Slackの特定チャンネルに投稿するというものを作成しました。
色々と躓いた点があったので如何にまとめておきます。だいぶ、Puppeteer側も時間が経過して色々変わっていました。
今回利用するツール等
- Puppeteer-core - npm
- xlsx-populate-wrapper - npm
- Node Slack SDK - Github
今回は、OAuth認証ではなく、APIキーを発行してSlack APIにリクエストを投げています。また、各種Slackの設定関係についてはJSONファイルに切り出してNode.js側で読み込む形にしています。
最終的には単一実行ファイル化して、ダブルクリックだけで仕事が終わるように構築する予定です。単一実行ファイル化については、以下のエントリーで説明しています。
事前準備
Slackの必要な情報を取得する
setting.jsonについて
今回以下の項目で取得した内容については、setting.jsonというファイル内に記述して、index.jsと同じディレクトリに入れています。JSONファイルの構造は以下のようなスタイルです。
{ "slacktoken" : "ここにAPIトークン文字列を入れる", "user_id" : "ここにメンション先のユーザのIDを入れる", "channel" : "ここに投稿先チャンネルのIDを入れる", "initial_msg" : "毎回初回投稿時の定型メッセージを入れる" }
定型メッセージを毎回変える必要がある場合は、ソースコードを直す必要があります。
SlackチャンネルのIDを取得する
あらかじめ用意しておいたSlackのチャンネルには各々個別のIDが付いたチャンネルのIDというものがあります。これを取得してsetting.jsonに書き込んでおきます。
- 対象のSlackチャンネルをブラウザで開く
- URLは以下のようなスタイルです
https://app.slack.com/client/テナントID/チャンネルID
- 上記のURLのうちチャンネルIDの部分をコピーしてsetting.jsonの該当箇所(channel)に書き込みます。
メンション先ユーザIDを取得する
今回は特定の人にメンションをつけて、画像投稿をお知らせするようにしています。このユーザIDとはメールアドレスではなく、各ユーザのプロファイル画面に表示されています。
- 適当にメンションなどのリンクをクリックするとその人のプロフィールが右サイドバーに開かれる
- 現地時間の下にある3つのボタンのうち、「︙」をクリック→メンバーIDをコピーをクリック
- 2.でコピーしたIDをsetting.jsonの該当箇所(user_id)に書き込みます。
図:メンバーIDがユーザ固有のIDです
アプリを作成しトークンを取得する
次にSlack上で動作するボットのアプリを作成して、Tokenを取得する必要があります。以下の手順で作成します。
- こちらのページにアクセスする
- Create New Appをクリックする
- From a manifestをクリックする
- Slack workspaceとしてすでに所有してるワークスペースを選択してNextをクリックする
- manifest画面はそのままCreateで次に進む。
- Review summaryはそのままCreateで次に進む。
- Basic Informationの画面になるので一番下の方にスクロールし、Display Informationを表示する
- App Nameを適当なものに変える。
- 上部にあるApp-Level-Tokensでは、Generate Token and Scopesをクリックする
- Add Scopeで今回はとりあえず3種類全部追加しています。
- Generateをクリックする
- 左サイドバーの「OAuth & Permissions」をクリックする
- 下の方にあるScopeに移動して、Add an OAuth Scopeをクリックする
- 今回自分は以下のスコープを入れていますが、files:write、chat:writeは必須ですがそれ以外はオプションです。
assistant:write calls:read calls:write channels:history channels:read chat:write files:write im:history users:read
- 上部にあるOAuth TokensでInstall to テナントというボタンをクリックして、このテナントにこのbotを追加します。
- 同時にBot User OAuth Tokenというのが表示されているハズなので、ここのCopyをクリックしてTokenを取得します。
- 取得したTokenをsetting.jsonの該当箇所(slacktoken)に記述します。
チャンネルにアプリを追加する
これで情報は取得できましたが、まだこれではボットがチャンネルに投稿できません。以下の作業を行い、チャンネルに作成したbotを追加しましょう。
- チャンネルを開く
- 右上の「︙」をクリックして、設定を編集するをクリックする
- インテグレーションをクリックして、Appの所にある「アプリを追加する」をクリックする
- 検索画面が出てくるので、前の項目でつけたApp Nameで検索し追加ボタンをクリックします。
Excelファイルを整備する
ここまででSlack関係の事前準備は完了しました。次に、スクリーンショットを連続で取得するのでデータを整備します。今回はkinoko.xlsxというファイルを用意して、その中に記述しています。
中には「kinoko」というシートが1枚あるだけで、ページ名, URLの2つの列があるだけです。この2列にページの名前とそれぞれスクショを撮るサイトのURLを記述しておきます。
図:Excelファイルに列挙しておく
使い手側での事前準備
デフォルトプロファイルでログイン
今回のPuppeteerでの自動化ではログイン自動化は装備していません。Puppeteer-coreで普段使ってるChromeを起動して使うようにしているため、もしログインを要するサイトのスクショを撮る場合には、Chromeのデフォルトプロファイルでサイトへのログイン→2段階認証済ませるというところまで完了しておく必要があります。
ターミナルを開いて以下のコマンドラインを実行し、開いたChromeで該当サイトへログインを完了し保存しておいてください。ユーザ名の場所は自身のログインユーザ名を入れてください。
//macOSの場合 /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --profile-directory=Default --user-data-dir=/Users/ユーザ名/Library/Application\ Support/Google/Chrome/Default //Windowsの場合 "C:\Program Files\Google\Chrome\Application\chrome.exe" --args --profile-directory=Default --user-data-dir=C:/Users/ユーザ名/AppData/Local/Google/Chrome/User\ Data
※但し、Windowsの場合、上記の例の場所にインストールされてるとは限らないので要注意(C:\Program Files (x86)\Google\Chrome\Application\chrome.exeというケースがある)。インストール先を見つけてパスは書き換えてください。
これで、Puppeteer-coreで起動したときも同じプロファイルを使って操作されるので、ログイン画面が出ることがなくなります。ユーザに毎回入力してもらい、ログイン完了まで待機させるという方法もあります。
うまくいかない場合は、初回だけPuppeteerで起動時にログインし、もう一度Puppeteerで実行すると良いでしょう。ログイン情報を利用して再度ログインは不要になります。
普段使ってるプロファイルのまま利用する
普段使ってるプロファイルのまま操作したいケースがあります。この場合、PuppeteerをLauchする時のuserDataDirの指定方法をDefaultではなく、以下のように指定すると良いです。
//macOSの場合 userDataDir = path.resolve(path.join(process.env.HOME || "", "Library/Application Support/Google/Chrome")); //Windowsの場合(前述同様) userDataDir = `C:\\Users\${username}\\AppData\\Local\\Google\\Chrome\\User Data`
ただし、ユーザが普段使ってるプロファイルで別個起動しようとすると「Error: Failed to launch the browser process!」というエラーが出て止まるので、Chromeは全て起動していない状態でなければこの手法は使うことが出来ません。注意が必要です。
ソースコード
冒頭の変数宣言等
冒頭ではモジュール読み込みや各種変数の設定を行っています。またPuppeteer-coreを使っているので、ユーザのPCにあるChromeのパスを特定しています。
'use strict'; //使用するモジュール const puppeteer = require('puppeteer-core'); const fs = require('fs'); const path = require("path"); const shell = require('child_process').exec; const os = require('os'); //xlsx読み取り const xlsxpop = require("xlsx-populate-wrapper"); //Excelファイルをロードする const xlsxfile = __dirname + "//kinoko.xlsx"; const workbook = new xlsxpop(xlsxfile); //Slack投稿 const { WebClient } = require('@slack/web-api'); //各種設定をsetting.jsonから取得する const settings = __dirname + "//setting.json"; const data = JSON.parse(fs.readFileSync(settings, 'utf8')); const channel = data.channel; const initmsg = data.initial_msg; const slacktoken = data.slacktoken; const user_id = data.user_id; //スレッドID格納用 var threadman = ""; //OS判定 const osver = process.platform; //ログインユーザ名を取得 const username = os.userInfo().username; //Chromeのパスを規定する //const homedir = require('os').homedir(); let chromepath = ""; let userDataDir = ""; if(osver == "darwin"){ //macOSの場合 chromepath = "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"; userDataDir = `/Users/${username}/Library/'Application Support'/Google/Chrome/Default` }else{ //Windowsの場合 userDataDir = `C:\\Users\${username}\\AppData\\Local\\Google\\Chrome\\User Data`; //各種ブラウザのパス const userHome = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"]; const kiteipath = "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"; const temppath = path.join(userHome, "AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"); const edgepath = "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"; if (fs.existsSync(kiteipath)) { chromepath = kiteipath } else { if (fs.existsSync(temppath)) { chromepath = temppath; } else { //Chromium Edgeの場合に対応 if(fs.existsSync(edgepath)){ chromepath = edgepath; }else{ //IEを起動してChromeのインストールを促す shellexec('start "" "iexplore" "https://www.google.co.jp/chrome/"') return; } } } } //Chromeの存在チェック if(fs.existsSync(chromepath)){ console.log("OK") }else{ console.log("Chromeがインストールされてないみたいですよ") return; } //puppeteer実行 main();
- setting.jsonを読み取ってSlack用のデータを変数に格納しています。
- xlsxファイルを読み取ってworkbookとして格納しています。
- 利用者のログインユーザ名を取得し、またOS情報を取得しています。
- 取得したOS情報でmacOSとWindowsの場合のそれぞれのChromeのパスを特定しています。また、DefaultプロファイルのCookieも使うので、userDataDirも指定しています。
- Windowsの場合はChrome以外にもEdgeでも動かせるのでChromeが無い場合にも対処させています。
- 最後にmain()を実行して、Puppeteerを実行します。
メインの処理
PuppeteerでChromeを操縦するメインの関数の処理です。Puppeteer-coreを使ってるのでChromiumではなく、インストール済みのChromeを使って操縦します。
//Puppeteerで自動操縦する async function main() { //puppeteer-coreを起動する //一回defaultプロファイルでログインが必要なサイトにはログインしておく必要がある const browser = await puppeteer.launch({ headless: false, executablePath: chromepath, userDataDir: userDataDir, args: ['--lang=ja'] }); //Excelファイルを読み込み var jsondata; var dlength; //ワークブック読み込み await workbook.init() .then(wb => { //ワークシートを読み込み jsondata = workbook.getData("kinoko"); dlength = jsondata.length; return; }) //作成するフォルダの日付 let foldate = getDateman(); //フォルダを作成する let dir = __dirname + "//" + foldate; let destination = fs.mkdirSync(dir, { recursive: true }); //同期的にループでJSONデータを入力していく for await(const json of jsondata) { //ExcelからページのURLを一個取得する let url = json["URL"]; //空の場合は終了する if(url == "" || url == undefined || url == null){ console.log("終了") break; } //ウェイトを入れる await sleep(5000) //ページ設定とタイムアウトデフォルト値設定 const page = await browser.newPage(); const timeout = 0; //0でNavigation timeout ofが出なくなる page.setDefaultTimeout(timeout); const navigationPromise = page.waitForNavigation(); await page.setViewport({ width: 2000, height: 1500, deviceScaleFactor: 2 }); //自動化対策に対する対策 await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', ()=>{}); delete navigator.__proto__.webdriver; }); //ページ名を取得する let pagename = json["ページ名"]; //Webページへ移動 await page.goto(url,{ timeout: 20000 }); await page.waitForNetworkIdle(5000) //スクショを撮る let targetpath = destination + "//" + pagename + ".png"; let targetfile = pagename + ".png"; await page.screenshot({path: targetpath, fullPage: true}); //slack投稿 let result = await slackmsg(targetpath, targetfile, pagename); //ページクローズ await page.close(); //ウェイトを入れる await sleep(2000) } //ブラウザを閉じる try{ await browser.close(); }catch(e){ } }
- 冒頭でChromeとuserDataDirを指定してpuppeteer-coreを起動します。
- ログインが必要なページの操作がある場合には、一度Default Profileにてログイン処理をしておく必要があります。
- 日付時間でスクショ格納用のフォルダを作成します。
- ページ名を元にスクショ画像のファイル名としています。
- for await ... ofの構文を使うと、Forループを同期的に回すことが可能です。
- 本来ページの初期化は一回のみで良いのですが、スクショを連続取得しようとすると止まるページがあったので、ページの初期化含めてループ内で行わせています。
- new Page()の前の時点でsleepを5000ms入れているのは、これを入れないと早すぎて「Session with given id not foundエラー」になる場合があるため。
- 対象ページを開いて、waitForNetworkIdleでロードが終わるまで待機させます。
- スクショはpage.setViewportの画面サイズに影響を受けるので、viewportの値は小さすぎないように注意が必要。
- page.screenshotで保存場所とファイル名を指定するとスクショが撮影可能。この時、fullPageをtrueにしておくとFireshot同様にページ全体をスクショ撮影します。
- slack投稿の関数にファイルのパス、ファイル名、ページ名を渡してスクショをそのまま投稿します。
- 連続してスクショを撮ろうとしたら、特定のページで何故か停止してしまったので、ここで一旦pageをcloseする処理を入れています。
- Slack APIのQuotaに引っかからないように、Sleepで2秒間ウェイトを入れています。必要に応じて数値を盛ります。
- 最後にbrowser.closeを実行して閉じて完了。
その他の関数
ここではSlackに投稿する関数、フォルダ用の日付生成関数、スリープ用の関数を用意しています。
//slackmessageを投稿する async function slackmsg(filepath,filename,textman){ //クライアント初期化 const web = new WebClient(slacktoken); //スレッドIDが空っぽの場合だけ実行 if(threadman == "" || threadman == null || threadman == undefined){ //メンション先 let mention = "<@" + user_id + ">\n"; //送信テキスト const text = mention + initmsg; //threadmanが空なので、初期投稿してthread_tsを取得する let response = await web.chat.postMessage({ channel, text }); threadman = JSON.parse(response.ts); } //ファイル送信 const result = await web.filesUploadV2({ channel_id: channel, initial_comment: textman, thread_ts: threadman, file_uploads: [ { file: filepath, filename: filename, } ], }); //終了 return 0; } //現在日付データを整形して返す関数 function getDateman(){ //日付を生成 let newdate = new Date(); let year = newdate.getFullYear(); let month = paddingZero(newdate.getMonth() + 1); let date = paddingZero(newdate.getDate()); //時刻を生成 let hour = paddingZero(newdate.getHours()); let mins = paddingZero(newdate.getMinutes()); let seconds = paddingZero(newdate.getSeconds()); //日付時刻を成形して返す let strDate = year + "_" + month + "_" + date + "_ " + hour + "_" + mins + "_" + seconds; return strDate; } //頭に0をつける var paddingZero = function(n) { return (n < 10) ? '0' + n : n; }; //スリープ用関数 function sleep(milliSeconds) { return new Promise(resolve => setTimeout(resolve, milliSeconds) ); }
- Slack APIのファイルアップロードのメソッドだけだと投稿したメッセージのthread_tsというスレッドIDが取得出来ないので、まずは通常のメッセージだけをメンションで投稿し、thread_tsをthreadmanに格納します。
- メンションはusernameが使えなくなってるので、取得済みのuser_idの値を持ってしてメンションをします。
- web.filesUploadV2でファイルをアップロードします。Puppeteerで生成したファイルを指定し、スレッド返信するためにthread_tsにも指定を入れます。
- file_uploadsにて複数のファイルを同時にアップロードも可能です。
実行結果
さて、これでnode index.jsをターミナルから実行してみると、Default Profileで起動したPuppeteerが起動して、Excel記載のURLを元に次々にスクショを撮ってSlackに投稿してくれます。最後に自動で閉じて完了となります。
以下はPuppeteerの操作の画面のみですが、この速度で次々にスクショを取得してはSlackに投稿を連続で自動で行っています。
図:元スレッドにぶら下げて画像が投稿されました
Windowsでトラブル
概要
前述のコード、macOSでは何ら問題なく動作しましたが、Windowsで同じコードを実行すると、Node Slack SDKでトラブルが出ていて、投稿ができない状態です。具体的にはNode Slack SDKの中で呼び出してる「Axios」というモジュールが以下のようなトラブルを起こしています(IntelおよびARMの両方の環境で確認しています)。
[WARN] web-api:WebClient:0 http request failed Invalid character in header content ["User-Agent"] node:internal/process/promises:394 triggerUncaughtException(err, true /* fromPromise */); ^ TypeError [ERR_INVALID_CHAR]: Invalid character in header content ["User-Agent"] at ClientRequest.setHeader (node:_http_outgoing:702:3) at new ClientRequest (node:_http_client:296:14) at Object.request (node:https:381:10) at dispatchHttpRequest (C:\Users\username\Documents\xpost\node_modules\axios\dist\node\axios.cjs:3000:21) at C:\Users\username\Documents\xpost\node_modules\axios\dist\node\axios.cjs:2690:5 at new Promise (<anonymous>) at wrapAsync (C:\Users\username\Documents\xpost\node_modules\axios\dist\node\axios.cjs:2670:10) at http (C:\Users\username\Documents\xpost\node_modules\axios\dist\node\axios.cjs:2708:10) at Axios.dispatchRequest (C:\Users\username\Documents\xpost\node_modules\axios\dist\node\axios.cjs:4135:10) at Axios._request (C:\Users\username\Documents\xpost\node_modules\axios\dist\node\axios.cjs:4415:33) at Axios.request (C:\Users\username\Documents\xpost\node_modules\axios\dist\node\axios.cjs:4287:41) { code: 'ERR_INVALID_CHAR' }
- axiosだけ古いものに置き換えても変わらず。
- Nodeのバージョンを23から古い22や20に変えても変わらず。
- StackOverflowでも報告されてる内容を確認しましたが、解決に至らず。
- Puppeteerやキャプチャについては問題なく動作しています。
Node Slack SDKのIssueにも報告は特になく、何が原因か不明であるため、これをFetch APIに置き換えようと思います。
Fetch APIで書き直す
と言ってもSDKと違ってかなり大変。アップロードは少なくとも
- files.uploadはもうすぐ廃止されます
- 代わりに以下の手順でファイルをアップロードする必要があります。
- files.getUploadURLExternalでまずアップロード準備。ファイルID, Upload URLというのが手に入る。
- Upload URLに対してPOSTで画像をBlob型にしたものをつけて実際にアップロードする
- files.completeUploadExternalにて、実際にアップロードしたファイルに対して情報を加えてSlackにポストする。
- 上記の3回投げることでようやくSDKと同じ挙動を実現することが可能です。
となっています。実際に書き換えたところ、WindowsでもmacOSでも問題なく画像のアップと投稿ができるようになりました。以下ソースコードです。slackmsg関数を書き直します。
//slackmessageを投稿する async function slackmsg(filepath, filename, textman) { //送信エンドポイント let msgpoint = "https://slack.com/api/chat.postMessage"; let storepoint = "https://slack.com/api/files.getUploadURLExternal" let filepoint = "https://slack.com/api/files.completeUploadExternal"; //リクエストヘッダ let headers = { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': "Bearer " + slacktoken } //スレッドIDが空っぽの場合だけ実行 if (threadman == "" || threadman == null || threadman == undefined) { //メンション先 let mention = "<@" + user_id + ">\n"; //送信テキスト let textmsg = mention + initmsg; //送信payload let params = { text: textmsg, channel: channel } //送信オプション let options = { method: 'post', headers: headers, body: JSON.stringify(params) } //HTTPリクエスト let response; await fetch(msgpoint, options).then((res) => { // Response を JSON に変換 return res.json(); }).then((json) => { // JSON を String に変換 response = JSON.stringify(json); //textを返す return textman; }); //返り値をとる let retman = JSON.parse(response); threadman = retman.ts; } //ウェイトを入れる sleep(2000) //ファイル送信用の前準備をする //ファイルサイズを取得する let stat = fs.statSync(filepath); let filesize = stat.size; //ヘッダ指定 let headers0 = { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': "Bearer " + slacktoken, } //オプション指定 let options0 = { method: 'get', headers: headers0 } //HTTPリクエスト let identifier; storepoint = storepoint + "?filename=" + filename + "&length=" + filesize await fetch(storepoint, options0).then((res) => { // Response を JSON に変換 return res.json(); }).then((json) => { // JSON を String に変換 identifier = json; //textを返す return identifier; }); //file_idを取得する let retman2 = identifier; let fileid = retman2.file_id; //アップロード先URLを構築する let upurl = retman2.upload_url + "?filename='@" + filename + "'"; //アップロードするファイルをBlobで取得する let image = fs.readFileSync(filepath); //ファイルをバイナリデータに変換する let BinaryData = new Uint8Array(image); let blob = new Blob([BinaryData], { type: 'image/png' }); let obj = new FormData(); obj.append("imageman", blob); //ウェイトを入れる sleep(2000) //バイナリデータをアップする await fetch(upurl, { method: "POST", body: obj }).then(response => { if (response.ok) { console.log("バイナリデータ送信完了"); } }).catch(err => { console.log("エラー:", err) }); //ウェイトを入れる sleep(2000) //ファイルのアップロード完了を実行 //リクエストヘッダ let header2 = { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': "Bearer " + slacktoken } //送信パラメータ let params2 = { channel_id: channel, initial_comment: textman, thread_ts: threadman, files: [{ id: fileid, title: filename }] } //送信オプション let options2 = { method: 'post', headers: header2, body: JSON.stringify(params2) } //HTTPリクエスト let uploaded; await fetch(filepoint, options2).then((res) => { // Response を JSON に変換 return res.json(); }).then((json) => { // JSON を String に変換 uploaded = json; //textを返す return uploaded; }); //終了 return 0; }
- 初回だけ合計4回HTTPリクエストが必要です。
- fetch APIをawaitで実行し、レスポンスを取得するといったことの繰り返しになります。
- ファイルアップロード準備だけGETリクエストで、エンドポイントURLに対してファイルの名前やファイルサイズを引数で繋げます。
- 準備時にファイルIDとアップロードURLが手に入るので、これに対してPOSTで画像を送信する。
- 画像はBlobで取得して送る必要性があります。Unit8Arrayやnew Blobを経てバイナリオブジェクトにしています。
- アップロードが完了したら、最後のリクエストで完了リクエストを持ってSlackに画像添付した状態で投稿します。
- アップ完了リクエストではSDKと違ってparamはfilesとしてファイルIDとファイル名をつける仕様になっています。
関連リンク
- Puppeteerによるフルページスクリーンショットを画像遅延読み込みに対応させる
- Puppeteerでヘッドレス Chromeのフルページスクリーンショットを撮る
- Slack APIのTokenの取得・場所(Legacy tokens)
- 【Node.js入門】forEachで繰り返し処理とasync.eachの使い方まとめ!
- for await...of
- Puppeteerでpage.waitForTimeout' is deprecated.
- Puppeteer でループ処理を同期的に実行しスクリーンショットを撮るサンプル
- Page.waitForNetworkIdle() method
- puppeteer で、スクレイピングでシンプルに GET で html を取得する処理
- [Bug]: Navigating frame was detached #11515
- TimeoutError: Navigation timeout of 30000 ms exceededの解消方法
- Slack API Rate Limit
- chat.postMessage
- files.completeUploadExternal
- Take screenshots of different elements with specific names in Puppeteer
- How to create a directory if it doesn't exist using Node.js
- 【GAS】「TypeError: Assignment to constant variable」と出る
- 【Node.js】Slack API を使用してメッセージを投稿する
- Slack APIで任意のチャンネルにメッセージを投稿する
- files.upload API 廃止におけるGASの改修
- [python] [slack] files.upload API 廃止に伴う対応 requestsモジュール編
- Responses from filesUploadV2 have files nested in the files array #1803
- slacker.error: not_allowed_token_typeで困っている君へ
- SlackのAPI経由でのメンションの仕様変更(2018/9/12~)
- How to Take Screenshot in Puppeteer: Complete Guide
- Slackのfiles.getUploadURLExternalでどハマりした
- 【Slack】files.upload API 廃止に伴う書き換え curl もしくは Python Request関数を使って
- Fetch API - Handling Binary Data