i-plug Advent Calendar 2019 の【17日目】の記事です
私事ですが、2019/12/17本日はエンジニアとして職につき1周年になります。
それの記念っぽく、長めの記事を投下します。
あるスプリントで...
弊社とある架空の動物部門の業務フローで毎日決まったキャットフードをgoogle詳細検索してきてその結果をpdf出力してファイリングするといったものがありました。これを効率化してほしいと依頼がありました。(フェイクです。)
つまり、手作業でやってる工程を
キーワードぽちーだけでpdfにできるノンエンジニア向けツールを作るということです。
できるようにしたいこと
キャットフードを詳細検索 + pdf出力 を(できる限り)自動でできること
ノンエンジニア向けであるということは
まず思いついたのはコマンドラインツールでした。
Node.js + Seleniumでスクリプトを書きnodeコマンドで実行すれば操作なしで自動でpdfを取ってくれると考えました。
しかし!
問題がありました。
カインドネスに作らないとみんな辛い
操作が複雑、環境構築の必要は
使うのは開発部門ではなく、他部門のノンエンジニアの方です。
しかもwin端末。
よって
できる限り複雑な操作を必要としないものがいいのです。
(私達開発者が思っている以上に簡単明快でシンプルでなければならない)
さらに
使用するにあたって環境構築が不要であるほどいいということです。
言わずもがな、エンジニアでも時には環境構築につまずくわけで、win端末でわざわざ動作環境を作り上げるのは骨が折れるし、作業担当者が変わってPCが変わったなどで発生するメンテナンスのコストが高いのです。
そして
コマンドラインツールだと、ノンエンジニアにとってUXは最悪。
並列に際して、tarminalを複数開いて叩いてもらうわけですが、そもそもスペックとかの問題できるのかすら怪しく、一番最初の複雑な操作を必要としないから反します。
まとめると次の通りです。
つまりポイントは3つ?
シンプルかつ簡単操作であること(ワンクリックレベル)
GUIであること(コマンドラインはきつい)
環境構築不要であること(OS違い・メンテナンスを気にしなくていいように)
この要件を目指してノンエンジニア向けの社内ツールを作ればきっと使い易く役に立つはずです
逆にこれらを気をつけないと、
開発者は作り損とメンテナンスコストで疲弊しガス欠に、
ノンエンジニアの方は逆に使いづらくて効率化されてるのかわからないし、むしろストレスフルになったりしそうです。
いざ実装〜!
みんなうれしい、カインドフルな構成とは?
環境構築が不要でOS・環境依存がしないことを目指すと簡単な社内Webアプリにすることにしました。
さらに、小さなアプリなので手をかけたくないのでサーバーレスアーキテクチャを採用。
そして、クライアントサイドはデプロイせずに必要な人にHTMLファイルの配布することにしました。これはセキュリティーやdomain取得、デプロイの手間を省く為です。
これらを踏まえて次の通りの構成になりました。
インフラはAWS Lambdaを使う
AWS Lambdaの構築はServerless Frameworkで実行
API内部の処理は Node.js + puppeteer で作成
GUIは HTML + CSS + JavaScript で作成
Serverless Framework
AWS Lambda を使用するには AWS Gatewayの設定などが必要でAPIとして使うにはそれ単体では使えません。
この構築を一元で行うものがServerless Frameworkです。
参考にさせていただきました記事 : Serverless Frameworkの使い方まとめ
ただ、自分の知見の関係でAWS Lambdaを選択しましたが
今だと AWS Amplify や Firebase なども検討しそうです。
(料金の関係はわからないけど小規模ならどれも変わらない気がする。)
APIを作成 Node.js + puppeteer
ポイントは2つ
fontを設定する
バイナリを返す処理にする
'use strict'
const puppeteer = require('puppeteer-core')
const chromium = require('chrome-aws-lambda')
module.exports.google = async event => {
const executablePath = event.isOffline
? "< local-chromium の path >"
: await chromium.executablePath
const query = event.queryStringParameters.query
if (query === undefined) {
// エラーハンドリング
}
const browser = await puppeteer.launch({
args: chromium.args,
executablePath
})
const page = await browser.newPage()
await page.goto('https://www.google.com/advanced_search')
await page.type('#CwYCWc', query)
await page.type('#mSoczb', '安い おいしい 無添加 健康')
await page.click('body > div.bottom-wrapper > div.Mza7yc > form > div:nth-child(5) > div:nth-child(9) > div.jYcx0e > input')
await page.waitForNavigation()
const url = await page.url()
await page.goto(url + '&num=100', {
waitUntil: 'domcontentloaded'
})
await page.evaluate(() => {
var style = document.createElement('style')
style.textContent = `
@import url('//fonts.googleapis.com/css?family=Source+Code+Pro');
@import url('//fonts.googleapis.com/earlyaccess/notosansjp.css');
div, input, a{ font-family: 'Noto Sans JP', sans-serif !important; };`
document.head.appendChild(style)
})
const pdfStream = await page.pdf()
return {
statusCode: 200,
isBase64Encoded: true,
headers: {
"Content-type": "application/pdf; charset=UTF-8",
"Access-Control-Allow-Origin" : "*",
"Access-Control-Allow-Credentials": "true"
},
body : pdfStream.toString('base64')
}
}
フォントを設定する
Node.js + puppeteer をローカルで実行する場合はローカルで設定されている日本語フォントが適応されます。
しかし、AWS Lambdaの内部で動いているのはまっさらなLinuxのコンテナです。
日本語フォントはもちろん入っていません。
なので
以下の記述でヘッドレスブラウザに日本語フォントを設定してあげる必要があります。
await page.evaluate(() => {
var style = document.createElement('style')
style.textContent = `
@import url('//fonts.googleapis.com/css?family=Source+Code+Pro');
@import url('//fonts.googleapis.com/earlyaccess/notosansjp.css');
div, input, a{ font-family: 'Noto Sans JP', sans-serif !important; };`
document.head.appendChild(style)
})
バイナリを返す処理
await page.pdf('hoge.pdf')
のようにfilepathを指定するとコンテナ内部にpdfがダウンロードされてしまいます。よってpdfそのままをクライアントに返すことができません。
以下のようにfilepathを未指定のまま、返り値を受けるとバイナリが受け取ることができます。
これをクライアントへレスポンスとして返してあげるとクライアントでpdfを生成することができるようになります!
const pdfStream = await page.pdf()
return {
statusCode: 200,
isBase64Encoded: true,
headers: {
"Content-type": "application/pdf; charset=UTF-8",
"Access-Control-Allow-Origin" : "< クロスドメインの設定 >",
"Access-Control-Allow-Credentials": "true"
},
body : pdfStream.toString('base64')
}
クライアントは簡単なHTMLファイルだけに
ダウンロードとか大層なことはさせずにZIPファイルでの配布だけで終わるようにするため
HTML + CSS + JavaScript をすべて1つのファイルにまとめました。
これで配布は超簡単に
配布されたフォルダの中にファイルがいくつも存在していたら、きっと見づらいのでちょっとした配慮ですよ。
ポイントは1つ
バイナリーデータを受け取ってJavaScriptでファイルを生成 / 自動ダウンロード化
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="content-language" content="ja">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/encoding-japanese/1.0.30/encoding.min.js"></script>
<title>Document</title>
</head>
<body>
<style type="text/css">
html {
background-color: #f0f8ff;
width: 100%;
}
.main-container{
width: 100%;
display: flex;
}
/* 直接CSSを埋め込みます */
</style>
<div class="main-container">
<div class="cat-container">
<div class="cat-form-section">
<h2>キャットフードのPDFを取得します〜</h2>
<span>キャットフード名:</span><input class="cat-container-form" type="text" id="cat-form" onkeypress="eventEnter(getAPI)">
</div>
<div class="loading-section displaynone">
<p>Googleからpdfを取得中です。しばらくお待ちください。</p>
<div class="loader"></div>
</div>
</div>
</div>
</body>
<script>
// JavaScriptも埋め込んでしまう //
const _base64ToArrayBuffer = base64 => {
const binary_string = window.atob(base64)
const len = binary_string.length
const bytes = new Uint8Array(len)
for (var i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i)
}
return bytes.buffer
}
// いろんな処理を中略... //
// eventEnter: func -> func | void
const eventEnter = (func) => {
if( window.event.keyCode == 13 ){
func()
}
}
const getAPI = () => {
let query = document.getElementById("cat-form").value
showGoogleLoading()
const targetUrl = '< ここにAWS Lambdaで作ったAPIのエンドポイント >'
$.ajax({
type: 'GET',
url: targetUrl,
data: {
query: query
}
})
.done(function(res) {
let decodedPdf = _base64ToArrayBuffer(res);
let blob = new Blob([ decodedPdf ], { 'type' : 'application/pdf' });
let blobURL = window.URL.createObjectURL(blob)
let a = document.createElement('a')
a.href = blobURL
a.download = 'google' + query + '.pdf'
document.body.appendChild(a)
a.click()
a.parentNode.removeChild(a)
document.getElementById("cat-form").value = ""
hideGoogleLoading()
})
.fail(function(e) {
alert('pdf取得に失敗しました、数分後再度取得を試みてください。');
hideGoogleLoading()
})
}
</script>
</html>
バイナリーデータを受け取ってJavaScriptでファイルを生成 / 自動ダウンロード化
デコード
さきほど作成したAPIからのレスポンスはbase64エンコーディングが施されています。
const _base64ToArrayBuffer = base64 => {
const binary_string = window.atob(base64)
const len = binary_string.length
const bytes = new Uint8Array(len)
for (var i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i)
}
return bytes.buffer
}
const getAPI = () => {
// 中略 ...
.done(function(res) {
let decodedPdf = _base64ToArrayBuffer(res);
// 中略 ...
})
}
この処理でデコードを実施しています。
pdfファイル生成 / 自動ダウンロード処理
const getAPI = () => {
// 中略 ...
.done(function(res) {
let decodedPdf = _base64ToArrayBuffer(res);
let blob = new Blob([ decodedPdf ], { 'type' : 'application/pdf' });
let blobURL = window.URL.createObjectURL(blob)
let a = document.createElement('a')
a.href = blobURL
a.download = 'google' + query + '.pdf'
document.body.appendChild(a)
a.click()
a.parentNode.removeChild(a)
// 中略 ...
})
}
デコードしたバイナリからpdfファイルを生成しています。
let decodedPdf = _base64ToArrayBuffer(res);
let blob = new Blob([ decodedPdf ], { 'type' : 'application/pdf' });
これからブラウザ上に表示させるfilepathを作成して、filepathのurlを持ったaタグを生成します。
let blobURL = window.URL.createObjectURL(blob)
let a = document.createElement('a')
a.href = blobURL
a.download = 'google' + query + '.pdf'
document.body.appendChild(a)
そして作成したaタグをクリックする処理をすればダウンロードが自動で行われます。
a.click()
小ネタ
const eventEnter = (func) => {
if( window.event.keyCode == 13 ){
func()
}
}
エンターキーを押したときだけ指定した関数が発火する為に
関数を受ける関数を作成しました。
無事に完成して配布したら〜
やったで完成したったわ
ZIPに圧縮して〜これをちょいちょいのちょい〜っと〜!!
「完成しましたさかい、確認よろしゅうお願いしまっさ〜」っとぽち〜!配布も終わりや
~~ 数日後 ~~
「すごく使いやすい!」
「助かりました!」
やったやで〜
どれぐらい効果あったのか
業務としてだいたい30分~1時間ほど圧縮できたそうです。
すこしは効率化できて良かったですね。