- 投稿日:2021-08-09T22:36:34+09:00
【Mac】ReactとPython flaskを使ったWebアプリ
Reactの基本的な勉強を済ませ、簡易アプリのソースを読んで知見を蓄え中の身です。本記事は、下記の記事に触発され、愛用しているMacbookのローカル環境で実行した時のメモです。 どんなアプリ? mecabを使った分かち書きスクリプトを使って、フロントで受け取った入力テキストをサーバー側で分かち書きをし、その結果をフロントで表示するという非常にシンプルなアプリです。 どんな構成? フロント側はReact、サーバー側はpython flaskで実装しています。 実装環境 macOS Big Sur 11.2.3 Python: 3.9.6 flask==1.0.2 npm: 7.20.3 1. Mecabのインストール 日本語の形態素解析エンジン"MeCab"をMacにインストールしてPython3から利用します。 $ brew install mecab $ brew install mecab-ipadic $ brew install git curl xz yarn $ git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git もしくは $ git clone --depth 1 git@github.com:neologd/mecab-ipadic-neologd.git $ cd mecab-ipadic-neologd $ ./bin/install-mecab-ipadic-neologd -n $ brew install swig これでmecab-python3をpip installできるようになりました。 2. ソースをgit clone mecab-ipadic-neologdフォルダ内にいる場合は、別フォルダに移動した方が良いでしょう。ここでは親フォルダに移動します。その後、ソースコードをgit cloneします。 $ cd .. $ git clone https://github.com/Pu-of-Parari/jparser_app.git $ cd jparser_app $ ls README.md backend frontend ref 3. サーバー側の準備 まだ動きません。python3の仮想環境内で必要なモジュールをインポートします。 いま、jparser_appフォルダ内にいます。 $ cd backend venvという名前で仮想環境を作成します。 $ python3 -m venv venv $ source venv/bin/activate 一応、pipをアップグレードします。 $ python3 -m pip install --upgrade pip requirements.txtを用いて、必要なモジュールをインストールします。 $ pip install -r requirements.txt 手順1を飛ばし、homebrewでmecabをあらかじめインストールしておかないと、ここでエラーが出るでしょう。 4. バックエンドのサーバを立てる いま、backendフォルダ内にいます。 $ python3 server.py 問題が起きなければ、これでバックエンドサーバの準備は完了です。 5. フロントエンドのサーバを立てる まず、フロントエンド用に先ほどとは別のターミナルを立ち上げてください。立ち上げたら、jparser_app/frontend/appフォルダに移動します。 $ cd jparser_app/frontend/app axiosがインストールされていない場合は、axiosをインストールします。 $ npm install axios node_modulesフォルダを作成します。 git cloneした場合、node_modulesフォルダは基本的にありません。 local環境に依存するため、通常は.gitignoreで無視します。 $ npm install フロントエンドサーバを立てます。 $ yarn start これでフロントエンドサーバの準備は完了です。 そして、全ての準備が完了です。お疲れさまでした。 localhost:3000にアクセス ブラウザからlocalhost:3000にアクセスします(yarn startで自動で開きます)。 app screenshots
- 投稿日:2021-08-09T22:25:58+09:00
【Handsontable】複数行ヘッダー(NestedHeaders)もどきの実現
はじめに Handsontableの有料版(バージョン 7.0.0以降、または Pro版)では、NestedHeadersオプションを使用することで、複数行ヘッダーを実現することができます。 MITライセンス版のバージョン 6.2.2を使用して、複数行ヘッダーを実現してみたかった。 参考用に検索すると、下記2つの記事が見つかります。 仕様 HandsontableのNestedHeadersオプションでは、構成配列要素にlabel、colspanプロパティを持つオブジェクトを指定するようになっています。 例 nestedHeaders: [ ['A', {label: 'B', colspan: 8}, 'C'], ['D', {label: 'E', colspan: 4}, {label: 'F', colspan: 4}, 'G'], ['H', 'I', 'J', 'K', 'L', 'M', 'N', 'R', 'S', 'T'] ] 「Handsontableのヘッダーを複数行やセルの結合に見せかける方法」で提示しているヘッダー部分を実現しようとすると左寄せなどがなく、colspanプロパティだけでは足りないため、classとstyleプロパティを追加しました。 例 let nestedHeaders = [ [{ label: '氏名', style: 'border-bottom: none' }, { label: '2018', colspan: 6, class: 'htLeft' }, { label: '2019', colspan: 6, class: 'htLeft'}], ['', '7', '8', '9', '10', '11', '12', '1', '2', '3', '4', '5', '6'] ]; let isRowHeader = false; let header = getHeaderHtml(nestedHeaders, isRowHeader); let hot = new Handsontable(document.getElementById('grid'), { rowHeaders: isRowHeader, afterGetColHeader: function(col, TH) { // thead内の要素を削除 $('table.htCore thead').empty(); // thead内に要素を追加 $('table.htCore thead').prepend(header); } getHeaderHtml 関数の引数に、ヘッダーの構成配列要素とヘッダー行有無(行番号部分)を指定します。 afterGetColHeader オプションにて、ヘッダーの再描画を行います。 ヘッダーの列数分毎回呼ばれてヘッダーを全部作り直しています。無駄なようなんですが、スクロールや列幅変更したときにヘッダーが壊れるので仕方ないです。でも、最初にヘッダー文字列を生成してしまっているので大したコストではない。 下記のCSSは結合セル(colspan)で、非表示セルにhiddenHeaderクラスを指定するために必要となります。 結合セルで使用 .handsontable thead th.hiddenHeader:not(:first-of-type) { display: none; } 実装 CodePen がQiitaで埋め込みが出来るのですが、表示が真っ白になってしまうので埋め込みはやめました。 参考記事と違って年の部分はセル結合されています。また、データ部分は数値型にしてみました。 ソースコード <!DOCTYPE html> <html lang="jp"> <body> <head> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.css"> <style> .handsontable thead th.hiddenHeader:not(:first-of-type) { display: none; } </style> </head> <div id="grid"></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script language="javascript" type="text/javascript"> let nestedHeaders = [ [{ label: '氏名', style: 'border-bottom: none' }, { label: '2018', colspan: 6, class: 'htLeft' }, { label: '2019', colspan: 6, class: 'htLeft'}], ['', '7', '8', '9', '10', '11', '12', '1', '2', '3', '4', '5', '6'] ] let data = [ ['*** ***', 90, 70, 88, 100, 92, 95, , 98, 99, 100, 55, 60 ], ['*** ***', 89, , 88, 100, 92, 95, 97, 98, 55, 92, 55, 60 ], ['*** ***', 100, 70, 82, 99, 92, 95, 97, , 69, 88, 55, ], ['*** ***', 77, 91, 81, 75, 91, 75, 96, 91, 77, 96, 55, 60 ] ] let isRowHeader = false; let header = getHeaderHtml(nestedHeaders, isRowHeader) let hot = new Handsontable(document.getElementById('grid'), { data: data, manualColumnResize: true, colHeaders: true, columns: [ { type: 'text',width: 100 }, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, { type: 'numeric',width: 60}, ], rowHeaders: isRowHeader, afterGetColHeader: function(col, TH) { // thead内の要素を削除 $('table.htCore thead').empty(); // thead内に要素を追加 $('table.htCore thead').prepend(header); }, }); function getHeaderHtml(nestedHeaders, isRowHeader) { let headerHtml = ['']; for(const row of nestedHeaders) { headerHtml.push('<tr>'); for(const [index, value] of row.entries()) { if(index == 0) { if(isRowHeader) { headerHtml.push('<th class="">'); headerHtml.push(getThHtml("")); } } if(typeof value == 'object') { if(value.label != undefined) { headerHtml.push('<th class='); headerHtml.push(value.class != undefined ? '"' + value.class + '"' : '""'); headerHtml.push(value.colspan != undefined ? ' colspan="' + value.colspan + '"' : ""); headerHtml.push(value.style != undefined ? ' style="' + value.style + '"' : ""); headerHtml.push('>'); headerHtml.push(getThHtml(value.label)); for(let i = 1; i < value.colspan; i++) { headerHtml.push('<th class="hiddenHeader">' + getThHtml("")); } } } else { headerHtml.push('<th class="">'); headerHtml.push(getThHtml(value)); } } headerHtml.push('</tr>'); } return headerHtml.join(''); } function getThHtml(text) { // Handsontableが自動で生成するヘッダーの内容に合わせる return '<div class="relative"><span class="colHeader">' + text + '</span></div></th>'; } </script> </body> </html> その他の例 Handsomtableの複数行ヘッダーで画像検索して見つけたのを実装してみました。 参考1 HandsomtableのNestedHeadersの機能説明のサンプル https://handsontable.com/docs/api/nested-headers/#nestedheaders 参考1の実装 let nestedHeaders = [ ['A', { label: 'B', colspan: 8 }, 'C'], ['D', { label: 'E', colspan: 4 }, { label: 'F', colspan: 4 }, 'G'], ['H', { label: 'I', colspan: 2 }, { label: 'J', colspan: 2 }, { label: 'K', colspan: 2 }, { label: 'L', colspan: 2 }, 'M'], ['N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W'] ]; let isRowHeader = true; let header = getHeaderHtml(nestedHeaders, isRowHeader); 参考2 HandsomtableのIssuesから https://github.com/handsontable/handsontable/issues/5718 参考2の実装 let nestedHeaders = [ ['YEAR', { label: 'AMERICAN BRAND', colspan: 2 }, { label: 'JAPAN BRAND', colspan: 4 }], ['', 'Tesla', 'Ford', 'Nissan', 'Toyota', 'Honda', 'Mazda'] ]; let isRowHeader = false; let header = getHeaderHtml(nestedHeaders, isRowHeader); let rowHeaders = ['Mazda','Mazda','Datsun','Hornet','Hornet','Valiant','Duster','Merc 2'] 参考3 HandsomtableのIssuesから https://github.com/jrowen/rhandsontable/issues/204 参考3の実装 let nestedHeaders = [ [{ label: 'Nested Header', colspan: 11 }], [{ label: '1 to 4', colspan: 4 }, { label: '5 to 6', colspan: 2 }, { label: 'more 7 to 11', colspan: 5 }], ['mpg', 'cry', 'disp', 'hp', 'draft', 'wt', 'qsec', 'vs', 'am', 'gear', 'carb'] ]; let isRowHeader = false; let header = getHeaderHtml(nestedHeaders, isRowHeader); コーナーのセル結合は下線を消すことで実現しています。 ヘッダーの左寄せとコーナー下線消去 .ht_clone_left.handsontable tbody tr th { text-align: left; } .handsontable thead tr:first-child th:first-child { border-bottom: none } .handsontable thead tr:nth-child(2) th:first-child { border-bottom: none } 参考4 サイボウズのkintone カスタマイズ フォーラムから HandsonTableの表頭2行結合表記方法について 参考4の実装 let nestedHeaders = [ [{ label: '果物', colspan: 3 }, { label: '野菜', colspan: 3 }, { label: '動物', colspan: 3 }], ['りんご', 'みかん', 'メロン', 'ほうれん草', 'レタス', 'ねぎ', '牛', '羊', '鳥'] ]; let isRowHeader = true; let header = getHeaderHtml(nestedHeaders, isRowHeader); let rowHeaders = ['前日繰越','発注','在庫']; コーナーのセル結合は下線を消すことで実現しています。 コーナー下線消去 .handsontable thead tr:first-child th:first-child { border-bottom: none } 最後に 有償版のNestedHeadersオプションに遜色ない機能になっています。これで、MITライセンス版のバージョン 6.2.2でも複数ヘッダーを気軽に実装できるでしょう。 本当は脱Jqueryにしたかったのですが、下記の部分の書き換えがうまく行きませんでした。 もし分かる人がいたらコメントお待ちしています。 afterGetColHeader: function(col, TH) { // thead内の要素を削除 $('table.htCore thead').empty(); // thead内に要素を追加 $('table.htCore thead').prepend(header); }
- 投稿日:2021-08-09T21:15:56+09:00
Firebase JS SDK v9がバンドルサイズを激減した神アップデートだった話?
Overview みんな大好きFirebaseですが、JSのSDKのファイルサイズが大きいという問題がありました。 ファイルサイズが大きいということは、ページ描画に影響があるため扱いにくい印象でした。 あれから約二年、2021/08/09時点ではまだベータですが、導入したらバンドルサイズが激減したのでその紹介になります。 v9はコード構成をFunctionベースに変えるBreaking Changeのため、それなりのコード修正が必要になります。 本件は公式ブログでも記事がありますので、最初に読むといいと思います。 https://firebase.googleblog.com/2021/07/introducing-the-new-firebase-js-sdk.html Target reader Firebase JS SDK v8のユーザー FirebaseのAuthやFirestoreのサイズが大きいのが気なっていた方 Prerequisite Firebase JSを理解している 実行環境はNode V14系 Body どのようにしてFirebaseはファイルサイズを削減したのか? これはググってもらたほうがいいですが、Tree Shakingというテクニックで聞いたことある方も少なくないと思います。 簡単に言えば、未使用コードを除外してファイルサイズを小さくする技術です。 Tree Shakingにおいて需要なのは、未使用コードをわかりやすくするということです。 Firebaseはクラスベースの構成でしたが、これをFunctionベースにBreaking Changeすることで対応しています。 以下にv8とv9のFirestoreのドキュメント追加コードを掲載します。 v8.js db.collection("cities").add({ name: "Tokyo", country: "Japan" }) .then((docRef) => { console.log("Document written with ID: ", docRef.id); }); v9.js import { collection, addDoc } from "firebase/firestore"; const docRef = await addDoc(collection(db, "cities"), { name: "Tokyo", country: "Japan" }); console.log("Document written with ID: ", docRef.id); https://firebase.google.com/docs/firestore/manage-data/add-data?hl=ja#web-v9_6 v8はクラスベースのため、dbからcollectionにアクセスし、collectionのメソッドで追加しています。 v9はFunctionベースのため、addDocをライブラリからインポートし、引数としてcollectionをしています。 v9の変更はFunctionをパッケージからimportして、引数に対象のデータを入れるStyleになるので押さえておきましょう。 どうしてFunctionベースにする必要があるのか?簡単に言えば、未使用コードと分離が容易だからです。 v8の場合、collectionが持つメソッドが10個あれば、未使用でもそれらをインポートしてしまいます。 しかし、v9のaddDocをインポートするスタイルでは、残り9個の未使用メソッドは削減対象にできるという理屈です。 具体的な修正内容 公式ブログからリンクされているアップグレード手順がベースになります。 https://firebase.google.com/docs/web/modular-upgrade?hl=ja ただし、Breaking Changeになるため、この内容では圧倒的に参考コードが足りません。 そこで、お勧めしたいのでは、各種公式ドキュメントのコードです。 以下はFirestoreのデータ追加のページですが、Web v9としてv9のコードがサンプルにあるので頼りになります。 ここにないものは自分でちょっとググるか頑張る必要があります。 Storage編 Storageはまだv9のサンプルコードがないため、SDKのTS見ながら作りました。 ちょっと驚いたのはrefがそのままの名前でエクスポートされている点ですね。 インポートする際に名前変えるのもありかもしれないです。 v8.js const url = await storage.ref("http://example.com/hoge.json").getDownloadURL(); v8.js import { getStorage, ref, getDownloadURL } from "firebase/storage" import firebaseApp from "./firebase" const storage = getStorage(firebaseApp) const url = await getDownloadURL(ref(storage, "http://example.com/hoge.json")) Firestore編 Firestoreは公式にサンプルありますが、最初知らずに頑張りました? 特徴的なのはv8ではChainでつないでいましたが、v9ではQueryConstraint[]担っているので、ひたすら条件追加して最後に展開しています。 この辺が機械的な変更から、少し形を変えた変更になっている部分ですかね。 v8.js const getDocumentsFromStore = async (collectionName, tokens, limit, startDocument) => { let query = db.collection(collectionName); // AND条件連結 tokens.forEach(v => { query = query.where(`token.${v.key}`, '==', true); }); // 前回クエリーの続き if (startDocument) { query = query.startAfter(startDocument); } const snapshot = await query .limit(limit) // 一回の結果数制限 .get(); return snapshot } v9.js import { getFirestore, collection, query, where, getDocs, setDoc, addDoc, doc, startAfter, limit } from "firebase/firestore/lite" // v9で登場したFirestoreの計量版 import { initializeApp } from "firebase/app" import config from '../configs/firebase' // 個人の設定 const firebaseApp = initializeApp(config) const db = getFirestore(firebaseApp) const getDocumentsFromStore = async (collectionName, tokens, maxLength, startDocument) => { const src = collection(db, collectionName) // AND条件連結 const queryParams = tokens.map(v => { return where(`token.${v.key}`, '==', true) }) // 一回の結果数制限 queryParams.push(limit(maxLength)) // 前回クエリーの続き if (startDocument) { queryParams.push(startAfter(startDocument)) } const snapshot = await getDocs(query(src, ...queryParams)) return snapshot } 実際どのくらい減るの? 実際に変更した結果が以下です。 …ビルドによってチャンクファイルのインデックスが変わるのでわかりにくいですね? KBレベルのものを計算すると…42KB削減になります!!! 63.92 - 31.87 - 6.24 - 68.16 = -42.35KB 今回削減した中で大きいのは、Authの31.87KBで、Firestoreは68.16から64.3KBとほぼ変わらず? Firestoreはもともとfirebase/firestore/memoryというオフライン未対応版を使っていたためです。 フル機能版は90KB強のため、結構削減してます!!! …ご不満ですか?バンドルサイズ削減って大変なんです! ご不満ですか???そんな方向けに神FirebaseはFirestore向けにfirebase/firestore/liteというパッケージを用意しています。(ブログにセクションあり) 基本的なCRUDをサポートしたこいつを適用したらどうなったか? 64KB => 19KBと約45KB削減されました??? そう、v9の隠し玉はfirebase/firestore/liteでした。 ただクエリーするためだけに90KBのファイルをバンドルしていたのが、最新のv9では20KB以下のバンドルサイズ激減したんです!!! Conclusion これを読んでしまったら、すぐにでもv9に移行したくなったんじゃないでしょうか? v9はバンドルサイズの神アップデートなので、可能なら優先度高めで対応していいやつです。 ただし、まだベータですし、Breaking Changeなので、はやる気持ちをさえつつ十分にテストを行ったうえで移行しましょう。 段階的に移行するため、まずは構文のみを移行するためのcompatライブラリもあるようなので、そちらも確認しておきましょう。 v8までのクラスベースの記述は、あと数年で終わります。
- 投稿日:2021-08-09T19:58:23+09:00
Internet Explorerなんてサポートしたくないので、Internet Explorerでアクセスしてきた人に対応していませんとお知らせを表示する。
ほかのブラウザでは利用できるのに、Internet Explorer(以下 IE)では利用できない機能が多くあります。そのためWeb開発でIEをサポートに含めると、CSS, JavaScriptに結構細かいハックが必要になって工数が増えてしまいます。 なので、できるだけサポートはしたくないですよね。 ただなにも対応しなければ、もしかしたらIEでアクセスしてきた人に「表示が崩れるんだけど」と文句を言われるかもしれません。 なので、「IEは対応していないよ」とWEBページに表示する機能をつけましょう。 IEであるかの判定 IEであることの判定は以下のJavaScriptで判定できます。 userAgent = window.navigator.userAgent.toLocaleLowerCase(); if (userAgent.indexOf('msie') != -1 || userAgent.indexOf('trident') != -1) { window.alert('ieですね'); } window.navigatorでブラウザのユーザーエージェントを取得できます。 toLocaleLowerCase()で小文字に変換しています indexOf()でmsieかtridentが含まれているか判定しています。 備考 IE 7 まではユーザーエージェントにmsieが含まれています。 IE 8 ~ IE 10まではユーザーエージェントにmsieとtridentが含まれています。 IE 11ではユーザーエージェントにtridentが含まれています。 ということで以上を踏まえて必要最小限のHTMLを作成します。 index.html <html> <head> <link rel="stylesheet" href="./forIE.css" /> </head> <body> <div id='forIE'> <!-- IEで表示する内容 --> <h1>このサイトはお使いのブラウザに対応しておりません。</h1> </div> <div id='root'> <!-- 他のブラウザで表示するメインコンテンツ --> <h1>hello world</h1> </div> <script src="./forIE.js" async></script> </body> </html> forIE.js document.addEventListener('DOMContentLoaded', function () { var userAgent = window.navigator.userAgent.toLocaleLowerCase(); if (userAgent.indexOf('msie') != -1 || userAgent.indexOf('trident') != -1) { var forIE = document.getElementById('forIE'); forIE.style.display = 'block'; document.getElementById('root').style.display = 'none'; } }); forIE.css #forIE { display: none; } 解説 document.addEventListener('DOMContentLoaded', function() }{})でDOMの解析が終了してから表示の切り替えを行っています。 IEで表示するHTMLと、その他のブラウザで表示するHTMLを分離しています。 <div id='forIE'></div>で囲まれた部分がIEで表示される箇所です。サポートしているブラウザのリンクなどを記述しておくと親切かもしれません。 使用OSがWin10の時、Edgeで開くボタンを表示する。 もし訪問者がWin10であるのなら、Edgeがインストールされていると思われるので、Edgeで開くリンクを用意すると親切でしょう。 以下のようにすれば、Edgeで開くリンクを用意できます。 <a href="microsoft-edge:http://*****.com"> <span>Edgeで開く</span> </a> Windows 10であるかどうかの判別は以下のコードで行えます。 userAgent = window.navigator.userAgent.toLocaleLowerCase(); if (userAgent.indexOf('windows nt 10') != -1) { // Win 10での処理 } よって、IEでアクセスしてきたときには、対応していないメッセージを表示し、Win10のときはEdgeで開くボタンも用意する場合、コードは以下のようになります。 index.html <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <script src="./forIE.js" async></script> <link rel="stylesheet" href="./forIE.css" /> </head> <body> <div id="forIE"> <h1> このサイトはお使いのブラウザ(Internet Explorer)には対応しておりません </h1> <div id="forWin10"> <a href="microsoft-edge:http://*****.com">対応ブラウザの「Microsoft Edge」で開く</a> </div> </div> <div id="root"> <h1>Hello World</h1> </div> </body> </html> forIE.css #forIE { display: none; } #forWin10 { display: none; } forIE.js document.addEventListener('DOMContentLoaded', function () { var userAgent = window.navigator.userAgent.toLocaleLowerCase(); if (userAgent.indexOf('msie') != -1 || userAgent.indexOf('trident') != -1) { var forIE = document.getElementById('forIE'); forIE.style.display = 'block'; console.log(userAgent); document.getElementById('root').style.display = 'none'; // for win10 if (userAgent.indexOf('windows nt 10') != -1) { var forWin10 = document.getElementById('forWin10'); forWin10.style.display = 'block'; } } }); 偏見ですが、今でもIEを使っている方はそれほどパソコンに詳しいとは思えないので、「サポートブラウザで開いてね」って言ってもわからないと思うので、なるべく親切なUIを構築したいですね。 まとめ window.navigator.userAgent.toLocaleLowerCase();でユーザーエージェントが取得できる msieまたはtridentが含まれていたらIEだと判定できる <a href="microsoft-edge:http://*****.com">Edgeで開く</a> ← で「Edgeで開く」リンクを用意できる 以上です。 やっていることは簡単なことですが、みんさんの参考になれば幸いです。
- 投稿日:2021-08-09T18:12:23+09:00
文字コードを指定してURLエンコードを行う(Node.js)
文字コードを指定してURLエンコードしたい。日本語(が含まれるん)だもの。 そういう話です。 encodeURI()はUTF-8を表すエスケープシーケンスで置換される Node.jsを用いて、あるAPIをGETメソッドでリクエストする処理を実装していました。 そのAPIはクエリストリングを APIの名前?key1=value1&key2=value2&signature=認証用の値 にしてリクエスト送信してくださいね、ということで下記のようなソースでクエリストリングを作成。 // 例えばユーザーの情報を取得するAPI // API名称 const apiName = 'getUser'; // リクエスト内容 const params = { name : '則巻アラレ', age : 13 } /** * クエリストリングを作成する * @param {String} apiName API名称 * @param {Object} params リクエスト内容 * @returns クエリストリング */ function makeQueryString(apiName, params) { let queryString = `${apiName}?`; // リクエスト内容分ループ for (const key in params) { const value = params[key]; // 値 queryString += `${key}=${value}&`; } // 末尾に認証用の値を付与 queryString += `signature=xxx`; // URLエンコード return encodeURI(queryString); } // クエリストリングの作成 const queryString = makeQueryString(apiName, params); しかし、encodeURI()1を用いてURLエンコードを行ったクエリストリングを使って、GETリクエストを送信しても、該当ユーザーがいない、というレスポンスばかりが返ってきていました 認証エラーが返ってきていないので、認証は通っている...。 何がダメなんだ?とそのAPIの開発者向けドキュメントを再読すると、以下の記述。 「Windows-31JでURLエンコードを行ってください」 文字コード CP932 / Windows-31J CP932とは、日本語の文字などを収録した文字コード規格の一つで、Shift JIS規格を元にマイクロソフト(Microsoft)社が独自に拡張したもの。微妙に異なる複数の仕様がある。 〔......〕同社(注: Microsoft社)はCP932のインターネット上での識別名としてIANAに「Windows-31J」を登録し〔......〕 CP932とは - IT用語辞典 ぶっちゃけこの文字コード初めて聞いたな...と思いつつ、 encodeURI()について確認すると、確かにrepresenting the UTF-8 encoding of the character2と記載があるので、UTF-8の文字を表すエスケープシーケンスで置換されるようです。 先ほどのコードだと、以下クエリストリングは getUser?name=則巻アラレ&age=13&signature=xxx encodeURI()によって、以下に変換されます。 name部分は、UTF-8の文字を表す(らしい)エスケープシーケンスで置換されています。 getUser?name=%E5%89%87%E5%B7%BB%E3%82%A2%E3%83%A9%E3%83%AC&age=13&signature=xxx 今回は、URLエンコードが行われた文字列を、Windows-31Jの文字列として表す必要があるので、標準の関数は使えないですね 文字コードを指定してURLエンコードができるiconv-urlencode やっと本題。 UTF-8以外の文字コードを指定してURLエンコードを行う必要があるなら、 iconv-urlencodeというパッケージを用いることで、それが可能です。 上記サイトの説明によると、iconv-liteパッケージで指定可能な文字コード3であれば、URLエンコード/デコード可能なようです。日本語ではShift_JIS, Windows-31j等が使用可能です。 以下の実行環境で実施していきます。 $ node --version v14.17.4 $ npm --version 6.14.14 インストール。 npm install iconv-urlencode あとは先ほどのソースコードに、モジュールの読み込みを追加し、 関数内で作成したクエリストリングをURLエンコードして返すようにします。 const conv = require('iconv-urlencode'); const encoding = 'Windows-31j'; // 文字コード // ...... // 略 // ...... function makeQueryString(apiName, params) { let queryString = `${apiName}?`; // リクエスト内容分ループ for (const key in params) { const value = params[key]; // 値 queryString += `${key}=${value}&`; } // 末尾に認証用の値を付与 queryString += `signature=xxx`; // 文字コードを指定し、URLエンコード return conv.encode(queryString, encoding); // ★iconv-urlencodeを利用 } ...と上記ソースコードだと実行結果がこうなります。 # getUser?name=則巻アラレ&age=13&signature=xxx のURLエンコード結果 getUser%3Fname%3D%91%A5%8A%AA%83A%83%89%83%8C%26age%3D13%26signature%3Dxxx 今回は?, =, &は変換されてほしくないので、修正。 値のみURLエンコードするようにします。 function makeQueryString(apiName, params) { let queryString = `${apiName}?`; // リクエスト内容分ループ for (const key in params) { // ★値のみURLエンコードを行う(文字コードを指定) const value = conv.encode(params[key], encoding); queryString += `${key}=${value}&`; } // 末尾に認証用の値を付与 queryString += `signature=xxx`; return queryString; } 結果は以下のようになりました。 # getUser?name=則巻アラレ&age=13&signature=xxx のURLエンコード結果(Windows-31J) getUser?name=%91%A5%8A%AA%83A%83%89%83%8C&age=13&signature=xxx これで無事に文字コードWindows-31Jの文字列として、サーバ側でデコードができて、 指定ユーザーの情報がAPIのレスポンスで返ってくるようになりましたとさ、めでたしめでたs ......残念ですが、このままではある条件下でエラーになる場合があります。 値だけURLエンコードをしていると、数字の0が消える それは、以下のユーザーで検索を行ったときに起きました...。 // リクエスト内容 const params = { name : '則巻ターボ', age : 0 } # 作成されたクエリストリング getUser?name=%91%A5%8A%AA%83%5E%81%5B%83%7B&age=&signature=xxx おわかりいただけただろうか...。 そう、ageの値が欠けているのだ...。 ageの値を文字列の'0'とした場合はクエリストリングにage=0&...となりますが、 数字の0であった場合は、age=&...として値が欠けてしまいます。 今回は値が数字の0であった場合は、URLエンコードしないようにしました。 const value = params[key] === 0 ? params[key] : conv.encode(params[key], encoding); # getUser?name=則巻ターボ&age=0&signature=xxx のURLエンコード結果(Windows-31J) getUser?name=%91%A5%8A%AA%83%5E%81%5B%83%7B&age=0&signature=xxx ひとまず、(今回は)ヨシ! というわけで、文字コードを指定して、URLエンコードを行う方法でした。 (今回は本筋から逸れるので記載していませんが、実際は入力値のバリデーションチェックもしています) 利用するAPIの仕様から「=」と「&」をエンコードさせないためにencodeURI()を使用しています。encodeURIComponent()との違いはMDN Web Docsに例として記載があります ↩ 翻訳が若干分かりにくかったので、英語版から引用。ちなみにencodeURIComponent()も同様にUTF-8の文字列として表される。 ↩ iconv-liteのgithub内のwikiにサポートしている文字コードの一覧があります。Windows-31jが!指定!できる! ↩
- 投稿日:2021-08-09T17:41:06+09:00
【初心者向け】結局package.jsonって何なの?
概要 難しい活字は読む気が起きずこれまで目をそらしていたpackage.jsonだが,いつまでもこのままではいけないのでしっかり理解してみようと思い,重い腰をあげて色々と調べてみた.自分と同じような駆け出しエンジニアの方たち向けに,自分が調べたことの共有ができればと思う. 何かご指摘等ありましたらコメントをいただけますと幸いです. はじめに package.jsonがなんたるかを理解するうえでNode.jsとnpmの理解は必須であり,本記事もこの2つの知識を前提に書いていく.この2つをしっかり理解した上で読んで欲しい.分量が多くなりそうなので自分は書かない. 本記事ではpackage.jsonとはそもそも何なのか,どういった役割があるのか,どうやって作成するかの3点について書く.近いうちにpackage.jsonの中身については別途詳しくまとめる. package.jsonとは package.jsonを一言で表すならば,「アプリ開発の際に自分がインストールして使ったライブラリと同じライブラリを,他の開発者の人にも使ってもらうための情報が詰まったファイル」と自分は表したい(違和感を感じた先輩エンジニアの方がいらっしゃいましたらコメントください). ここでいう情報とは,例えば使用したライブラリやそのバージョン,ライブラリの依存関係などである.依存関係は少し専門的な用語になるが,イメージとしては「餃子をつくるためには皮と中身が必要で,中身をつくるにはさらに野菜と肉が必要で...」というように,何かの実行に別の何かが依存している,そんな構造だと思ってもらえると良い.「あるライブラリを動かすためには別のライブラリが必要で,そのライブラリにはまた別のが必要で...」といった感じ. package.jsonの恩恵 package.jsonの正体を書いたところで,「じゃああるとなんで良いの?」みたいな話をしていこうと思う.結論から言うと,npm installコマンドを使ってライブラリのインストールを行うことができることだ.これについて,少し遠回りだが噛み砕いた説明を試みる. 複数人で共同開発を行う際,使用したライブラリやそのバージョンなどが同じ環境で開発を行うことが望ましい.ライブラリが同じである必要性は自明だろうが,同じバージョンを使用する理由は,バージョンが変わるとコードの書き方などが変わる可能性があるからだ.これにより,他の人の開発時には動いていたコードが最新版になると動かなくなる,といったことが起こる可能性がある1. 同じバージョンの同じライブラリを自分の開発環境で復元する必要があることがおわかりいただけたと思うが,他人が使ったライブラリ全てをバージョンまでそろえて自分の手で探してきて自分の環境に復元する(インストールする)のは時間も手間もかかるのであまり現実的でない.さらには先述したような依存関係もあるので尚更だ. ここで,npm installコマンドが真価を発揮する.たった1行,このコマンドを叩くだけでpackage.jsonファイル内の情報を参照して,使用ライブラリやバージョン,依存関係まで全く同じものを一括でインストールしてくれる.だから,package.jsonは「お便利情報が詰まったファイル」だと自分は思う. package.jsonの作成方法 コマンドを1行実行するだけ.超簡単. $ npm init これだけでpackage.jsonファイルは作成できる.ちなみに驚くほど余談だが,自分は素直な少年なので初めてコマンドを操作した時は$マークまで含めてコマンドに打ち込んだのだが,当然エラーが起こって憤慨した.この行はコマンドで実行する操作ですよっていうのを表すために慣習として$マークを使っているだけらしく,全く必要ないので自分の二の舞にならないで欲しい. こんな話はさておき,上記コマンドを実行すると何やら小難しい英文がズラズラと出てくるが,package.jsonファイル内に書く情報の設定を行っているだけなので,作成したプロジェクトをパッケージとして公開するつもりが無ければ特に何もせずEnterキーを押し続けてもらって問題ない.そして最終的に以下のような画面になれば完了.Is this OK? 当然OKなので自信満々にEnterキーを押そう. This utility will walk you through creating a package.json file. It only covers the most common items, and tries to guess sensible defaults. See `npm help init` for definitive documentation on these fields and exactly what they do. Use `npm install <pkg>` afterwards to install a package and save it as a dependency in the package.json file. Press ^C at any time to quit. package name: (xxxxxx) version: (1.0.0) description: entry point: (index.js) test command: git repository: keywords: author: license: (ISC) About to write to C:¥Uses¥User¥Desktop¥test.qiita¥package.json: { "name": "test.qiita", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo ¥"Error: no test specified¥" && exit 1" }, "author": "", "license": "ISC" } Is this OK? (yes) 実際に他のコマンドとあわせて使ってみると,以下の感じ. $ mkdir xxxxx # xxxxxというディレクトリ(フォルダ)を作成 $ cd xxxxx # 作成したディレクトリ(フォルダ)へ移動 $ npm init -y # デフォルトの値でpackage.jsonを作成 $ npm install パッケージ名 # パッケージのインストール -yはオプションと呼ばれるもので,これを付けることで実行したいコマンドに追加的な指示を与えることができる.今回の場合はデフォルトの値でという指示を追加した.このオプションによってEnterキーを連打した時と全く同じpackage.jsonファイルを作成できる.もちろん,お好みで連打の方を選んでもらっても構わない. まとめ 本記事執筆のおかげで今まで理解しようと努めてこなかったpackage.jsonについて知る良い機会になったので良かった.周辺知識も少し増えたので,別の機会に書いてみたいと思う.これを読んでくれた駆け出しエンジニアの同士の皆さんがpackage.jsonについて少しでも理解が深まっていたら非常に嬉しい. 公式サイト NPM package.json(英語版) NPM package.json(日本語版) おススメ記事 今回の執筆にあたって拝読した記事の内,特にわかりやすくて他の人にもぜひ読んでいただきたいものをここに載せる. 【初心者向け】NPMとpackage.jsonを概念的に理解する 初学者向けpackage.jsonハンズオン ちなみに,こうしたことを起こさないためにpackage.jsonファイルに加えてpackage-lock.jsonファイルというのも作成される.また話が複雑になるので,本記事では割愛する. ↩
- 投稿日:2021-08-09T16:52:12+09:00
AtCoder Beginner Contest 213 ~JavaScriptの練習~
感想 リアタイで参加したが、実装ミスなどにより最悪な結果となった。 D問題までは難しくなかったが、E問題が大きな壁になった。 E問題のように、操作をコストと捉えてダイクストラや01BFSに落とすのは以前に見たことがあったが、実戦から離れすぎて忘れてしまった。 再び精進する気があまりないので、Kaggleなどの別の競技をやってみるのが良いのかもしれない。 A問題 考察 $A \oplus C=B \leftrightarrow C=A \oplus B$なので、xorを計算するのみ。 文法 入力の部分が面倒なので、簡素化した方が良いかもしれない。 それ以外は特に感じたことはなかった。 コード // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る input = input.split("\n"); let a,b;[a,b]=input[0].split(" ").map((x)=>{return parseInt(x,10)}); console.log(a^b) } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8")); 簡素ver function toInt(x){ return parseInt(x,10); } // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る input = input.split("\n"); let a,b;[a,b]=input[0].split(" ").map(toInt); console.log(a^b) } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8")); B問題 考察 スコアの降順に並べて2番目の数の番号を出力すれば良い。 出力するのは番号なので、スコアと番号を組にして持っておく。 文法 クラス 組にして持つために今回はクラスを使った。クラスについてはMDNのドキュメントに詳しいので、参考にされたい。 今回はクラス宣言を用いてクラスを定義した。Pythonでいうメンバ変数をプロパティと呼び、クラス内ではthis.プロパティ名によりアクセス/定義できる。クラス外ではインスタンス名.プロパティ名でアクセスできる。(通常の定義の場合はPrivateになる) ソート ソートがかなり癖がある。詳しくはMDNのドキュメントを参考にされたい。 まず、デフォルトの比較関数が、各要素を文字列に変換した際のUnicodeでのポイント順という仕様になっている。また、比較関数を自分で指定する時をここでは考える。 比較関数をcompareFunction(a,b)としたとき compareFunction(a,b)が0未満の時 aをbより小さいインデックスにソートする compareFunction(a,b)が0の時 aとbの位置を変えない(環境によっては保証されない) compareFunction(a,b)が0より大きい時 aをbより大きいインデックスにソートする コード class player{ constructor(ind,ban){ this.ind=ind; this.ban=ban; } } // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る let n,a,b; [n,b]=input.split("\n"); n=parseInt(n,10); a=[]; b=b.split(" "); for(let i=0;i<n;i++){ a.push(new player(i,parseInt(b[i],10))); } a.sort((i,j)=>{return j.ban-i.ban;}); console.log(a[1].ind+1); } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8")); C問題 考察 少なくとも一つのカードを含む行および列が残るので、座標圧縮を行えば良い。 出てくる行について重複がないように昇順に並べ、出てくる列について重複がないように昇順に並べることで座標圧縮を行うことができる。 最終的に求めるのは、それぞれのカードがどの座標へと移るかなので、出力の前にnowr[元の行]=移る先の行とnowc[元の列]=移る先の列を用意した。 文法 Set 重複を除くためにSetを利用した。new演算子によりインスタンスを生成でき、追加する際はaddメソッドを用いる。詳しくはMDNのドキュメントを参考にする。 オブジェクト(連想配列) オブジェクトに対してドット表記か角括弧表記のいずれかでアクセスすることができる。 ドット表記の場合はプロパティであることを意識してアクセスする。また、プロパティは文字列と結びついているので、角括弧表記を用いて連想配列としてアクセすることができる。この時、プロパティ名は文字列に変換できるものであれば使用することができる。今回の場合は整数を用いている。 ただ、連想配列としてのみの利用の場合はMapオブジェクトを用いた方が良いと思われる(参考)。 コード function compareNumbers(a,b){ return a - b; } // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る input = input.split("\n"); let h,w,n; [h,w,n]=input[0].split(" ").map((x)=>parseInt(x)); // r:行、c:列、候補 let r=new Set(); let c=new Set(); let card=[]; for(let i=1;i<=n;i++){ card.push(input[i].split(" ").map((x)=>parseInt(x))); r.add(card[i-1][0]); c.add(card[i-1][1]); } let nowr,nowc; [nowr,nowc]=[{},{}]; r=Array.from(r); r.sort(compareNumbers); c=Array.from(c); c.sort(compareNumbers); for(let i=0;i<r.length;i++){ nowr[r[i]]=i+1; } for(let i=0;i<c.length;i++){ nowc[c[i]]=i+1; } for(let i=0;i<n;i++){ console.log(nowr[card[i][0]],nowc[card[i][1]]); } } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8"));
- 投稿日:2021-08-09T16:17:47+09:00
[ABC Metric] ソフトウェアメトリックを使ってクソコードを滅ぼす
1. はじめに リーダブル秋山です。私達の世界にはクソコードという言葉があり、品質の低いコードを貶めるワードとして日々親しまれています。しかし、私達は一体どのようにクソコードを見分けているのでしょうか。その一つの指標として、ソフトウェアメトリックと呼ばれるコードの品質を測る方法論があります。 クソコードの定義は人により様々ですが、ここでは「複雑性の高いコード」と定義します。最も美しいコードはゼロ行のコードという極論が示唆するように、コードは可能な限り簡潔であるべきです。その理由として、コードの複雑性は可読性を下げ、再利用性を下げ、凝集度を下げ、拡張性を下げ、バグの温床になることが挙げられます1。 本記事ではソフトウェアメトリックについて概説した後、その中でも汎用的なABC Metricという手法を実例を交えて紹介し、どのようにクソコードとの戦いに活かすか検討します。 2. ソフトウェアメトリックとは何か 広義のソフトウェアメトリックは、ソフトウェア開発にまつわる様々なプロセスを定量評価できるあらゆる指標を指します。粒度が小さい順に例を挙げると、関数の行数、テストカバレッジ、応答速度、CPU使用率などがあります。ほか、ソフトウェア開発において定量評価できるものはソフトウェアメトリックと呼んで差し支えないでしょう。 表題であるクソコードと戦うためには、ソフトウェアメトリックの中でも、コードの複雑性を評価できる必要があります。そのような手法として、コード行数、FP、LCOMなど様々なものが発表されていますが、ここでは汎用性と実効性のバランスに優れたABC Metricと呼ばれる手法を取り上げます。 (LCOMについては下の記事で解説しています) 3. ABC Metricとは何か C++ Report誌上で1997年に発表された、コードの複雑性を測る指標です2。コードの行数よりも実用的な指標として考案されました。 ABC Metricは、簡単に言えばコード内の命令の個数です。命令を代入(Assignemnt)、分岐(Branch)、条件(Conditional)の3種類に分けカウントします。 それら3種類の命令の個数をそれぞれ$A, B, C$とすれば、ABC Metricは次式のベクトルとして表すことができます。 \overrightarrow{ABC} = (A, B, C) 通常はこのベクトルの絶対値を最終的なABC Metricの値として用います。 \rm{ABC} = |\overrightarrow{ABC}| = \sqrt{A^2 + B^2 + C^2} $\rm{ABC}$の値が大きいほど、複雑性の高いコードと見做します。 実際に各$A, B, C$をどのように計算するかは、言語毎にルールを定める必要があります。原文ではC言語、C++言語、Java言語に対してどのようなルールを設定するべきか示しており、それらを考慮すると次のような共通ルールを抽出できます。 命令の種類 ルール トークン例 $A$ 定数定義を除く代入演算 = *= /= %= += &= $A$ インクリメント/デクリメント ++ -- $B$ 関数呼び出し func() $B$ new/delete new Classdelete hoge $C$ 比較演算 == != <= >= < > $C$ 条件キーワード try else case default ? $C$ 単項条件式 if (a) という式の a の部分 このルールに従い、実際のコードについて計算してみましょう。 4. 計算例 ここではRubyとTypeScriptで書かれたコードを例に計算します。 4-1. Rubyでの計算例 Railsのレポジトリから次のコードを引用し計算します。 # rails/activerecord/lib/arel/table.rb (一部改変) def initialize(name, as: nil, klass: nil, type_caster: klass&.type_caster) @name = name.to_s # AB @klass = klass # A @type_caster = type_caster # A if as.to_s == @name # BC as = nil # A end @table_alias = as # A end したがって、ABC Metricの値は次のようになります。 \overrightarrow{ABC} = (5, 2, 1) \\ \rm{ABC} = |\overrightarrow{ABC}| = \sqrt{5^2 + 2^2 + 1^2} = 5.48 4-2. TypeScriptでの計算例 material-uiというレポジトリから、deepmergeを行う関数を引用し計算します。 // material-ui/packages/material-ui-utils/src/deepmerge.ts (一部改変) export default function deepmerge<T>( target: T, source: unknown, options: DeepmergeOptions = { clone: true }, ): T { const output = options.clone ? { ...target } : target; // ACC (三項演算子はif-elseと解釈) if (isPlainObject(target) && isPlainObject(source)) { // BB Object.keys(source).forEach((key) => { // BB // Avoid prototype pollution if (key === '__proto__') { // C return; } if (isPlainObject(source[key]) && // B key in target && // B isPlainObject(target[key]) // B ) { (output as Record<keyof any, unknown>)[key] = // A deepmerge(target[key], source[key], options); // B } else { // C (output as Record<keyof any, unknown>)[key] = source[key]; // A } }); } return output; } したがって、ABC Metricの値は次のようになります。 \overrightarrow{ABC} = (3, 8, 4) \\ \rm{ABC} = |\overrightarrow{ABC}| = \sqrt{3^2 + 8^2 + 4^2} = 9.43 このようにABC Metricはスカラ値として算出できるため、事前に閾値を定めておき、それを超えたら警告を出すという運用がなされます。 5. なぜABC Metricなのか コードの複雑性を測る指標として、ABC Metircの持つ利点は次の3つです。 5-1. 言語非依存 上の例で見てきたように、ABC Metricは特定の言語に依存しません。例えばコードの複雑性を測る指標としてFlogがありますが、これはRuby言語に特化しています。また凝集度を判定するLCOMという指標がありますが、これはJavaやSwiftのようなクラスモジュールを基本とする言語でなければ有効に機能せず、TypeScriptやGoのような関数ベースの言語には適用できないものです。 その点、ABC Metricは代入、分岐、条件といった、実用的な言語であれば必ず備えている要素を評価対象としているため、あらゆる言語に適用することができます。 5-2. 説明の容易さ ABC Metricは命令をカウントするだけという非常に分かりやすいアイデアであることは勿論ですが、命令を代入、分岐、条件に分けていることも重要です。ABC Metricのベクトル表現を見れば、代入、分岐、条件のいずれに比重が偏っているかが分かります。そのためプログラマはどの命令を減らすべきか容易に分かります。 5-3. 柔軟性 本記事ではABC Metricをスカラ値として表す際、単に絶対値を計算しましたが、次式のように重みベクトル$W$を設定することも可能です。私も実際に試したことはないですし、原著者も可能性について言及しているだけですが、面白いアイデアだと思います。 \overrightarrow{ABC} = (A, B, C) \\ \overrightarrow{W} = (W_A, W_B, W_C) \\ \rm{ABC} = |\overrightarrow{ABC} \,\cdot\, \overrightarrow{W}| = \sqrt{(AW_A)^2 + (AW_B)^2 + (AW_C)^2} 例えばGo言語ではエラーハンドリングの都合上、A(代入)とC(条件)のカウントが増えるため、これらを減じる重みを設定するという使い方が可能です。 6. で、実際にどう導入するの? ここまで丁寧にABC Metricについて見てきましたが、残念ながら主要なLinterの中でABC Metricのルールを備えているものはRubyのRuboCopしか知りません(他に知っていたら教えてください)。 Rubyistの方は引き続きRuboCopの恩恵に預かりましょう。他の言語の方はLinterのルールプラグインをぜひ作ってください(他力本願)。 7. おわりに コードの複雑性を定量評価できると何が嬉しいのかというと、自動化が可能になるからです。クソコードを避けるためには、SOLID原則をはじめとする経験則や、DDDに代表されるアーキテクチャパターンについて知っており、さらにある程度の実装経験を積んでいることが求められます。そのためクソコードを避けるためには膨大な時間がかかり、さらにコードレビューやモブプログラミングなどを通じてベテランから知識を引き出すことも必要になります。 定量評価のメリットは、そのようにベテランの手を借りることなく、クソコードに対して機械的に警告を受けられることにあります。警告の種類は限定的ではありますが、それでもゼロコストでコードの複雑性を減らしてくれるツールを利用しない手はないように思えます。 ABC Metricが考案されたのは20年以上前ですが、そのシンプルなアイデアは現代のプログラミングでも十分に活用でき、コードの複雑性を測る指標として再考されるべきものの一つだと言えます。 Twitter:リーダブル秋山@aki202 プログラマのプロとアマを分ける一つのポイントは、ソフトウェアを構成するコードベースから如何に複雑性を取り除けるかにあると思っています。動作するソフトウェアを構築するだけであれば誰にでもできますが、バグの発生率を抑え、将来の拡張性を見越した実装能力は、一長一短で身につくものではありません。これこそがコードで飯を食う我々に期待される能力かと思います。 ↩ 原文では "software size" を測る指標と言っていますが、ここでは簡単のために複雑性の指標と読み替えます。 ↩
- 投稿日:2021-08-09T16:17:47+09:00
[ABC Metric] ソフトウェアメトリックを導入してクソコードを滅ぼす
1. はじめに 私達の世界にはクソコードという言葉があり、品質の低いコードを貶めるワードとして日々親しまれています。しかし、私達は一体どのようにクソコードを見分けているのでしょうか。その一つの指標として、ソフトウェアメトリックと呼ばれるコードの品質を測る方法論があります。 クソコードの定義は人により様々ですが、ここでは「複雑性の高いコード」と定義します。最も美しいコードはゼロ行のコードという極論が示唆するように、コードは可能な限り簡潔であるべきです。その理由として、コードの複雑性は可読性を下げ、再利用性を下げ、凝集度を下げ、拡張性を下げ、バグの温床になることが挙げられます。 本記事ではソフトウェアメトリックについて概説した後、その中でも汎用的なABC Metricという手法を実例を交えて紹介し、どのようにクソコードとの戦いに活かすか検討します。 2. ソフトウェアメトリックとは何か 広義のソフトウェアメトリックは、ソフトウェア開発にまつわる様々なプロセスを定量評価できるあらゆる指標を指します。粒度が小さい順に例を挙げると、関数の行数、テストカバレッジ、応答速度、CPU使用率などがあります。ほか、ソフトウェア開発において定量評価できるものはソフトウェアメトリックと呼んで差し支えないでしょう。 表題であるクソコードと戦うためには、ソフトウェアメトリックの中でも、コードの複雑性を評価できる必要があります。そのような手法として、コード行数、FP、LCOMなど様々なものが発表されていますが、ここでは汎用性と実効性のバランスに優れたABC Metricと呼ばれる手法を取り上げます。 (LCOMについては下の記事で解説しています) 3. ABC Metricとは何か C++ Report誌上で1997年に発表された、コードの複雑性を測る指標です1。コードの行数よりも実用的な指標として考案されました。 ABC Metricは、簡単に言えばコード内の命令の個数です。命令を代入(Assignemnt)、分岐(Branch)、条件(Conditional)の3種類に分けカウントします。 それら3種類の命令の個数をそれぞれ$A, B, C$とすれば、ABC Metricは次式のベクトルとして表すことができます。 \overrightarrow{ABC} = (A, B, C) 通常はこのベクトルの絶対値を最終的なABC Metricの値として用います。 \rm{ABC} = |\overrightarrow{ABC}| = \sqrt{A^2 + B^2 + C^2} $\rm{ABC}$の値が大きいほど、複雑性の高いコードと見做します。 実際に各$A, B, C$をどのように計算するかは、言語毎にルールを定める必要があります。原文ではC言語、C++言語、Java言語に対してどのようなルールを設定するべきか示しており、それらを考慮すると次のような共通ルールを抽出できます。 命令の種類 ルール トークン例 $A$ 定数定義を除く代入演算 = *= /= %= += &= $A$ インクリメント/デクリメント ++ -- $B$ 関数呼び出し func() $B$ new/delete new Classdelete hoge $C$ 比較演算 == != <= >= < > $C$ 条件キーワード try else case default ? $C$ 単項条件式 if (a) という式の a の部分 このルールに従い、実際のコードについて計算してみましょう。 4. 計算例 ここではRubyとTypeScriptで書かれたコードを例に計算します。 4-1. Rubyでの計算例 Railsのレポジトリから次のコードを引用し計算します。 # rails/activerecord/lib/arel/table.rb (一部改変) def initialize(name, as: nil, klass: nil, type_caster: klass&.type_caster) @name = name.to_s # AB @klass = klass # A @type_caster = type_caster # A if as.to_s == @name # BC as = nil # A end @table_alias = as # A end したがって、ABC Metricの値は次のようになります。 \overrightarrow{ABC} = (5, 2, 1) \\ \rm{ABC} = |\overrightarrow{ABC}| = \sqrt{5^2 + 2^2 + 1^2} = 5.48 4-2. TypeScriptでの計算例 material-uiというレポジトリから、deepmergeを行う関数を引用し計算します。 // material-ui/packages/material-ui-utils/src/deepmerge.ts (一部改変) export default function deepmerge<T>( target: T, source: unknown, options: DeepmergeOptions = { clone: true }, ): T { const output = options.clone ? { ...target } : target; // ACC (三項演算子はif-elseと解釈) if (isPlainObject(target) && isPlainObject(source)) { // BB Object.keys(source).forEach((key) => { // BB // Avoid prototype pollution if (key === '__proto__') { // C return; } if (isPlainObject(source[key]) && // B key in target && // B isPlainObject(target[key]) // B ) { (output as Record<keyof any, unknown>)[key] = // A deepmerge(target[key], source[key], options); // B } else { // C (output as Record<keyof any, unknown>)[key] = source[key]; // A } }); } return output; } したがって、ABC Metricの値は次のようになります。 \overrightarrow{ABC} = (3, 8, 4) \\ \rm{ABC} = |\overrightarrow{ABC}| = \sqrt{3^2 + 8^2 + 4^2} = 9.43 このようにABC Metricはスカラ値として算出できるため、事前に閾値を定めておき、それを超えたら警告を出すという運用がなされます。 5. なぜABC Metricなのか コードの複雑性を測る指標として、ABC Metircの持つ利点は次の3つです。 5-1. 言語非依存 上の例で見てきたように、ABC Metricは特定の言語に依存しません。例えばコードの複雑性を測る指標としてFlogがありますが、これはRuby言語に特化しています。また凝集度を判定するLCOMという指標がありますが、これはJavaやSwiftのようなクラスモジュールを基本とする言語でなければ有効に機能せず、TypeScriptやGoのような関数ベースの言語には適用できないものです。 その点、ABC Metricは代入、分岐、条件といった、実用的な言語であれば必ず備えている要素を評価対象としているため、あらゆる言語に適用することができます。 5-2. 説明の容易さ ABC Metricは命令をカウントするだけという非常に分かりやすいアイデアであることは勿論ですが、命令を代入、分岐、条件に分けていることも重要です。ABC Metricのベクトル表現を見れば、代入、分岐、条件のいずれに比重が偏っているかが分かります。そのためプログラマはどの命令を減らすべきか容易に分かります。 5-3. 柔軟性 本記事ではABC Metricをスカラ値として表す際、単に絶対値を計算しましたが、次式のように重みベクトル$W$を設定することも可能です。私も実際に試したことはないですし、原著者も可能性について言及しているだけですが、面白いアイデアだと思います。 \overrightarrow{ABC} = (A, B, C) \\ \overrightarrow{W} = (W_A, W_B, W_C) \\ \rm{ABC} = |\overrightarrow{ABC} \,\cdot\, \overrightarrow{W}| = \sqrt{(AW_A)^2 + (AW_B)^2 + (AW_C)^2} 例えばGo言語ではエラーハンドリングの都合上、A(代入)とC(条件)のカウントが増えるため、これらを減じる重みを設定するという使い方が可能です。 6. で、実際にどう導入するの? ここまで丁寧にABC Metricについて見てきましたが、残念ながら主要なLinterの中でABC Metricのルールを備えているものはRubyのRuboCopしか知りません(他に知っていたら教えてください)。 Rubyistの方は引き続きRuboCopの恩恵に預かりましょう。他の言語の方はLinterのルールプラグインをぜひ作ってください(他力本願)。 7. おわりに コードの複雑性を定量評価できると何が嬉しいのかというと、自動化が可能になるからです。クソコードを避けるためには、SOLID原則をはじめとする経験則や、DDDに代表されるアーキテクチャパターンについて知っており、さらにある程度の実装経験を積んでいることが求められます。そのためクソコードを避けるためには膨大な時間がかかり、さらにコードレビューやモブプログラミングなどを通じてベテランから知識を引き出すことも必要になります。 定量評価のメリットは、そのようにベテランの手を借りることなく、クソコードに対して機械的に警告を受けられることにあります。警告の種類は限定的ではありますが、それでもゼロコストでコードの複雑性を減らしてくれるツールを利用しない手はないように思えます。 ABC Metricが考案されたのは20年以上前ですが、そのシンプルなアイデアは現代のプログラミングでも十分に活用でき、コードの複雑性を測る指標として再考されるべきものの一つだと言えます。 Twitter:リーダブル秋山@aki202 原文では "software size" を測る指標と言っていますが、ここでは簡単のために複雑性の指標と読み替えます。 ↩
- 投稿日:2021-08-09T15:44:56+09:00
【個人開発】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(②)
LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。 完成形としては以下の通りです。 アーキテクチャ 今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, 「Amazon DynamoDB」の3つを使用してアプリを作成します。 また、プロビジョニングやデプロイに関してはAWS SAM(Serverless Application Model)を使用します。 対象読者 ・ Node.jsを勉強中の方 ・ TypeScriptを勉強中の方 ・ インフラ初心者の方 ・ ポートフォリオのデプロイ先をどうするか迷っている方 作成の難易度は低めです。 理由は、必要なパッケージも少ないため要件が複雑ではないからです。 また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。 記事 今回は2つの記事に分かれています。 お気に入り店の登録や解除などを今回の記事で行っています。 お店の検索を行うところまでを前の記事で行っています。 前回の記事をやっていないと今回の記事は全く理解できないのでこちらからご確認ください。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの機能 今回は3つの機能を足していきます。 お気に入り登録 クライアント LINE Messaging API(バックエンド) ①メッセージを編集し、「行きつけ」ボタンを追加する ②「行きつけ」をタップする ③DynamoDBを作成する ④ポストバックのデータを元にDynamoDBに登録を行う お気に入り店を探す クライアント LINE Messaging API(バックエンド) ①「行きつけ」をタップする ②user_idを元にDynamoDB から検索を行う ③FlexMessageを作成する ④お店の情報をFlexMessageで送る お気に入り店の解除 クライアント LINE Messaging API(バックエンド) ①「行きつけを解除」をタップする ②user_idとtimestampを元にDynamoDBからデータを削除する ハンズオン! お気に入り登録を行う 機能 これだけじゃイメージがつきにくいと思うので完成図を先に見せます。 ①メッセージを編集し、「行きつけ」ボタンを追加する 「行きつけ」をタップすることで、お店の情報を渡したいのでポストバックアクションを使用します。 こちらを使うことで、dataプロパティの値を受け取ることができます。 普通にメッセージとして送ってもいいのですが、送られる返信がお店の情報になってしまいます。 あまりよろしくないので、採用を見送りました。 ということでやっていきましょう。 今回は前回の記事で作成したCreateFlexMessage.tsに追加していきます。 api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { sortRatingGourmetArray } from './SortRatingGourmetArray'; // types import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type'; export const createFlexMessage = async ( user_id: string | undefined, googleMapApi: string ): Promise<FlexMessage | undefined> => { return new Promise(async (resolve, reject) => { try { // modules sortRatingGourmetArray const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray( user_id, googleMapApi ); // FlexMessage const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => { // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`; const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: photoURL, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: gourmet.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${gourmet.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: storeDetailsURL, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: storeRoutingURL, }, }, + { + type: 'button', + action: { + type: 'postback', + label: '行きつけ', + data: `lat=${gourmet.geometry_location_lat}&lng=${gourmet.geometry_location_lng}&name=${gourmet.name}&photo=${gourmet.photo_reference}&rating=${gourmet.rating}&vicinity=${gourmet.vicinity}`, + displayText: '行きつけにする', + }, + }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: '近隣の美味しいお店10店ご紹介', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; ②「行きつけ」をタップする こちらはクライアント側での操作なのでやることはありません。 ③DynamoDBを作成する 前回の記事では、SAMテンプレートでDynamoDBを作成したのですが、今回は手動で作成します。 DBは以下のような値を持つレコードを作成していきます。 PK SK K K K K K user_id timestamp photo_url name rating store_details_url store_routing_url ユーザー ID タイムスタンプ 店舗の写真 店舗の名前 店舗の評価 店舗詳細 店舗案内 ソートキーを使う場合どのようにSAMを使うのかの記載が見つからなかったので手動とします。(SAMテンプレートでのやり方を知っている方がいましたらお教えいただけますと幸いです。) DynamoDBを作ったことがない人もいると思うので、一応画像で説明します。 名前は何でもいいです。 一応自分は、Gourmets_Favoriteで作成しています。 先に作成しているのでエラーメッセージ出てますが気にしないでください。 ④ポストバックのデータを元にDynamoDBに登録を行う まずは関数を呼び出しているindex.tsから記載していきます。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; + import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); + await actionPutFavoriteShop(response, googleMapApi); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; + // FlexMessageの「行きつけ」をタップしたらそのお店が登録される + const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: + string) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const timestamp = event.timestamp; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite === -1) { + // Register data, userId in DynamoDB + await putFavorite(data, timestamp, userId, googleMapApi); + } + } catch (err) { + console.log(err); + } + }; では、DynamoDBにデータを追加するコードを書いていきましょう。 データの追加はputを使用します。 また、次にポストバックのデータの使用方法に関してです。 { "type":"postback", "label":"Buy", + "data":"action=buy&itemid=111", "text":"Buy" } データはこのように渡されます。 この値をどのように取得するかお分かりでしょうか? JavaScriptに慣れている方であればすぐにお分かりでしょうね! 指定した区切り文字で分割して文字列の配列にしましょう。 ということで使うのは、splitですね。 ということでやっていきましょう。 api/src/Common/Database/PutFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putFavorite = ( data: string, timestamp: number, userId: string | undefined, googleMapApi: string ) => { return new Promise((resolve, reject) => { // data const dataArray = data.split('&'); const lat = dataArray[0].split('=')[1]; const lng = dataArray[1].split('=')[1]; const name = dataArray[2].split('=')[1]; const photo = dataArray[3].split('=')[1]; const rating = dataArray[4].split('=')[1]; const vicinity = dataArray[5].split('=')[1]; // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${photo}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${name} ${vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; const params = { Item: { user_id: userId, timestamp: timestamp, photo_url: photoURL, name: name, rating: rating, store_details_url: storeDetailsURL, store_routing_url: storeRoutingURL, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets_Favorite', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; これで完了です。 それでは次に、お気に入りのお店を探しましょう。 お気に入り店を探す 機能 こちらも先にどのような機能かお見せします。 「行きつけ」をタップしたら、お気に入り登録したお店の一覧が表示されます。 ①「行きつけ」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idを元にDynamoDBから検索を行う DynamoDBからお気に入りのお店の情報を取得しましょう。 今回は複数取得する可能性が高いのでqueryを使用します。 ということでやっていきましょう。 api/src/Common/TemplateMessage/Favorite/QueryDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const queryDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets_Favorite', ExpressionAttributeNames: { '#u': 'user_id' }, ExpressionAttributeValues: { ':val': userId }, KeyConditionExpression: '#u = :val', }; docClient.query(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ③FlexMessageを作成する DynamoDBから取得した値を使用してFlexMessageを作成していきましょう。 api/src/Common/TemplateMessage/Favorite/MakeFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { queryDatabaseInfo } from './QueryDatabaseInfo'; // types import { Item, QueryItem } from './types/MakeFlexMessage.type'; export const makeFlexMessage = async (userId: string | undefined): Promise<FlexMessage> => { return new Promise(async (resolve, reject) => { try { // modules queryDatabaseInfo const query: any = await queryDatabaseInfo(userId); const queryItem: QueryItem = query.Items; // FlexMessage const FlexMessageContents: FlexBubble[] = await queryItem.map((item: Item) => { const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: item.photo_url, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: item.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${item.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: item.store_details_url, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: item.store_routing_url, }, }, { type: 'button', action: { type: 'postback', label: '行きつけを解除', data: `timestamp=${item.timestamp}`, displayText: '行きつけを解除する', }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: 'お気に入りのお店', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 独自の型定義があるのでファイルを作成しましょう。 api/src/Common/TemplateMessage/Favorite/types/MakeFlexMessage.type.ts export type Item = { user_id: string; photo_url: string; rating: string; timestamp: number; name: string; store_routing_url: string; store_details_url: string; }; export type QueryItem = Item[]; ④お店の情報をFlexMessageで送る 最後にFlexMessageで送信しましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; + import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // console.log(JSON.stringify(response)); // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); + await actionTapFavoriteShop(client, response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; + // リッチメニューの「行きつけ」をタップしたらメッセージが送られる + const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'text') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const text = event.message.text; + + if (text === '行きつけのお店') { + const flexMessage = await makeFlexMessage(userId); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); + } else { + return; + } + }; お気に入り店の解除 機能 「行きつけを解除」をタップするとデータが消去されます。 ①「行きつけを解除」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idとtimestampを元にDynamoDBからデータを削除する こちらも同様にポストバックを使用します。 { "type": "postback", "label": "行きつけを解除", "data": `timestamp=${item.timestamp}`, "displayText": "行きつけを解除する", } こちらもsplitを使って値を取得しましょう。 次にDynamoDBの削除は、deleteを使用します。 api/src/Common/Database/DeleteFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const deleteFavorite = (data: string, userId: string | undefined) => { return new Promise((resolve, reject) => { // data const timestamp: number = Number(data.split('=')[1]); const params = { TableName: 'Gourmets_Favorite', Key: { user_id: userId, timestamp: timestamp, }, }; docClient.delete(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ではこの関数を読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; + import { deleteFavorite } from './Common/Database/DeleteFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); await actionTapFavoriteShop(client, response); + await actionDeleteFavoriteShop(response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; // リッチメニューの「行きつけ」をタップしたらメッセージが送られる const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const text = event.message.text; if (text === '行きつけのお店') { const flexMessage = await makeFlexMessage(userId); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } }; + // FlexMessageの「行きつけを解除」をタップしたらそのお店がDBから削除される + const actionDeleteFavoriteShop = async (event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite !== -1) { + // Delete Gourmets_Favorite + await deleteFavorite(data, userId); + } + } catch (err) { + console.log(err); + } + }; これで完了です。 すべての機能を盛り込みました。 これでアプリとしては十分使えると思います。 まぁまだ問題点はあります。 FlexMessageは1度で12個しかスクロールできません。 なので、お気に入り店舗が12以上になると表示する方法がありません。 12以上の場合は複数回返信を行うように設定してもいいのですが、 店舗数が増えれば増えるほど見辛くなる問題も孕んでいます。 ただでさえ1つで画面占有の6割以上です。 これを2つ、3つと増やした場合はユーザビリティの悪化に繋がります。 なので残念ながらこれ以上の対応は思いつかないということで、簡易的なお気に入り機能として使っていただけると幸いです。 終わりに LINE Messaging APIを使うことでフロントの開発から解放されます。 LINEという身近なツールを使うことでインストールなどの手間もなく、これがあると便利だなってものを簡単に制作することができます。 ぜひ皆さんもLINE Bot開発をしてみてください。 ここまで読んでいただきありがとうございました。
- 投稿日:2021-08-09T15:44:56+09:00
【個人開発/LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(②)
LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。 完成形としては以下の通りです。 アーキテクチャ 今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, 「Amazon DynamoDB」の3つを使用してアプリを作成します。 また、プロビジョニングやデプロイに関してはAWS SAM(Serverless Application Model)を使用します。 対象読者 ・ Node.jsを勉強中の方 ・ TypeScriptを勉強中の方 ・ インフラ初心者の方 ・ ポートフォリオのデプロイ先をどうするか迷っている方 作成の難易度は低めです。 理由は、必要なパッケージも少ないため要件が複雑ではないからです。 また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。 記事 今回は2つの記事に分かれています。 お気に入り店の登録や解除などを今回の記事で行っています。 お店の検索を行うところまでを前の記事で行っています。 前回の記事をやっていないと今回の記事は全く理解できないのでこちらからご確認ください。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの機能 今回は3つの機能を足していきます。 お気に入り登録 クライアント LINE Messaging API(バックエンド) ①メッセージを編集し、「行きつけ」ボタンを追加する ②「行きつけ」をタップする ③DynamoDBを作成する ④ポストバックのデータを元にDynamoDBに登録を行う お気に入り店を探す クライアント LINE Messaging API(バックエンド) ①「行きつけ」をタップする ②user_idを元にDynamoDB から検索を行う ③FlexMessageを作成する ④お店の情報をFlexMessageで送る お気に入り店の解除 クライアント LINE Messaging API(バックエンド) ①「行きつけを解除」をタップする ②user_idとtimestampを元にDynamoDBからデータを削除する ハンズオン! お気に入り登録を行う 機能 これだけじゃイメージがつきにくいと思うので完成図を先に見せます。 ①メッセージを編集し、「行きつけ」ボタンを追加する 「行きつけ」をタップすることで、お店の情報を渡したいのでポストバックアクションを使用します。 こちらを使うことで、dataプロパティの値を受け取ることができます。 普通にメッセージとして送ってもいいのですが、送られる返信がお店の情報になってしまいます。 あまりよろしくないので、採用を見送りました。 ということでやっていきましょう。 今回は前回の記事で作成したCreateFlexMessage.tsに追加していきます。 api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { sortRatingGourmetArray } from './SortRatingGourmetArray'; // types import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type'; export const createFlexMessage = async ( user_id: string | undefined, googleMapApi: string ): Promise<FlexMessage | undefined> => { return new Promise(async (resolve, reject) => { try { // modules sortRatingGourmetArray const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray( user_id, googleMapApi ); // FlexMessage const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => { // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`; const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: photoURL, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: gourmet.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${gourmet.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: storeDetailsURL, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: storeRoutingURL, }, }, + { + type: 'button', + action: { + type: 'postback', + label: '行きつけ', + data: `lat=${gourmet.geometry_location_lat}&lng=${gourmet.geometry_location_lng}&name=${gourmet.name}&photo=${gourmet.photo_reference}&rating=${gourmet.rating}&vicinity=${gourmet.vicinity}`, + displayText: '行きつけにする', + }, + }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: '近隣の美味しいお店10店ご紹介', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; ②「行きつけ」をタップする こちらはクライアント側での操作なのでやることはありません。 ③DynamoDBを作成する 前回の記事では、SAMテンプレートでDynamoDBを作成したのですが、今回は手動で作成します。 DBは以下のような値を持つレコードを作成していきます。 PK SK K K K K K user_id timestamp photo_url name rating store_details_url store_routing_url ユーザー ID タイムスタンプ 店舗の写真 店舗の名前 店舗の評価 店舗詳細 店舗案内 ソートキーを使う場合どのようにSAMを使うのかの記載が見つからなかったので手動とします。(SAMテンプレートでのやり方を知っている方がいましたらお教えいただけますと幸いです。) DynamoDBを作ったことがない人もいると思うので、一応画像で説明します。 名前は何でもいいです。 一応自分は、Gourmets_Favoriteで作成しています。 先に作成しているのでエラーメッセージ出てますが気にしないでください。 ④ポストバックのデータを元にDynamoDBに登録を行う まずは関数を呼び出しているindex.tsから記載していきます。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; + import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); + await actionPutFavoriteShop(response, googleMapApi); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; + // FlexMessageの「行きつけ」をタップしたらそのお店が登録される + const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: + string) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const timestamp = event.timestamp; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite === -1) { + // Register data, userId in DynamoDB + await putFavorite(data, timestamp, userId, googleMapApi); + } + } catch (err) { + console.log(err); + } + }; では、DynamoDBにデータを追加するコードを書いていきましょう。 データの追加はputを使用します。 また、次にポストバックのデータの使用方法に関してです。 { "type":"postback", "label":"Buy", + "data":"action=buy&itemid=111", "text":"Buy" } データはこのように渡されます。 この値をどのように取得するかお分かりでしょうか? JavaScriptに慣れている方であればすぐにお分かりでしょうね! 指定した区切り文字で分割して文字列の配列にしましょう。 ということで使うのは、splitですね。 ということでやっていきましょう。 api/src/Common/Database/PutFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putFavorite = ( data: string, timestamp: number, userId: string | undefined, googleMapApi: string ) => { return new Promise((resolve, reject) => { // data const dataArray = data.split('&'); const lat = dataArray[0].split('=')[1]; const lng = dataArray[1].split('=')[1]; const name = dataArray[2].split('=')[1]; const photo = dataArray[3].split('=')[1]; const rating = dataArray[4].split('=')[1]; const vicinity = dataArray[5].split('=')[1]; // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${photo}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${name} ${vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; const params = { Item: { user_id: userId, timestamp: timestamp, photo_url: photoURL, name: name, rating: rating, store_details_url: storeDetailsURL, store_routing_url: storeRoutingURL, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets_Favorite', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; これで完了です。 それでは次に、お気に入りのお店を探しましょう。 お気に入り店を探す 機能 こちらも先にどのような機能かお見せします。 「行きつけ」をタップしたら、お気に入り登録したお店の一覧が表示されます。 ①「行きつけ」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idを元にDynamoDBから検索を行う DynamoDBからお気に入りのお店の情報を取得しましょう。 今回は複数取得する可能性が高いのでqueryを使用します。 ということでやっていきましょう。 api/src/Common/TemplateMessage/Favorite/QueryDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const queryDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets_Favorite', ExpressionAttributeNames: { '#u': 'user_id' }, ExpressionAttributeValues: { ':val': userId }, KeyConditionExpression: '#u = :val', }; docClient.query(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ③FlexMessageを作成する DynamoDBから取得した値を使用してFlexMessageを作成していきましょう。 api/src/Common/TemplateMessage/Favorite/MakeFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { queryDatabaseInfo } from './QueryDatabaseInfo'; // types import { Item, QueryItem } from './types/MakeFlexMessage.type'; export const makeFlexMessage = async (userId: string | undefined): Promise<FlexMessage> => { return new Promise(async (resolve, reject) => { try { // modules queryDatabaseInfo const query: any = await queryDatabaseInfo(userId); const queryItem: QueryItem = query.Items; // FlexMessage const FlexMessageContents: FlexBubble[] = await queryItem.map((item: Item) => { const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: item.photo_url, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: item.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${item.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: item.store_details_url, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: item.store_routing_url, }, }, { type: 'button', action: { type: 'postback', label: '行きつけを解除', data: `timestamp=${item.timestamp}`, displayText: '行きつけを解除する', }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: 'お気に入りのお店', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 独自の型定義があるのでファイルを作成しましょう。 api/src/Common/TemplateMessage/Favorite/types/MakeFlexMessage.type.ts export type Item = { user_id: string; photo_url: string; rating: string; timestamp: number; name: string; store_routing_url: string; store_details_url: string; }; export type QueryItem = Item[]; ④お店の情報をFlexMessageで送る 最後にFlexMessageで送信しましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; + import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // console.log(JSON.stringify(response)); // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); + await actionTapFavoriteShop(client, response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; + // リッチメニューの「行きつけ」をタップしたらメッセージが送られる + const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'text') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const text = event.message.text; + + if (text === '行きつけのお店') { + const flexMessage = await makeFlexMessage(userId); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); + } else { + return; + } + }; お気に入り店の解除 機能 「行きつけを解除」をタップするとデータが消去されます。 ①「行きつけを解除」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idとtimestampを元にDynamoDBからデータを削除する こちらも同様にポストバックを使用します。 { "type": "postback", "label": "行きつけを解除", "data": `timestamp=${item.timestamp}`, "displayText": "行きつけを解除する", } こちらもsplitを使って値を取得しましょう。 次にDynamoDBの削除は、deleteを使用します。 api/src/Common/Database/DeleteFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const deleteFavorite = (data: string, userId: string | undefined) => { return new Promise((resolve, reject) => { // data const timestamp: number = Number(data.split('=')[1]); const params = { TableName: 'Gourmets_Favorite', Key: { user_id: userId, timestamp: timestamp, }, }; docClient.delete(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ではこの関数を読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; + import { deleteFavorite } from './Common/Database/DeleteFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); await actionTapFavoriteShop(client, response); + await actionDeleteFavoriteShop(response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; // リッチメニューの「行きつけ」をタップしたらメッセージが送られる const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const text = event.message.text; if (text === '行きつけのお店') { const flexMessage = await makeFlexMessage(userId); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } }; + // FlexMessageの「行きつけを解除」をタップしたらそのお店がDBから削除される + const actionDeleteFavoriteShop = async (event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite !== -1) { + // Delete Gourmets_Favorite + await deleteFavorite(data, userId); + } + } catch (err) { + console.log(err); + } + }; これで完了です。 すべての機能を盛り込みました。 これでアプリとしては十分使えると思います。 まぁまだ問題点はあります。 FlexMessageは1度で12個しかスクロールできません。 なので、お気に入り店舗が12以上になると表示する方法がありません。 12以上の場合は複数回返信を行うように設定してもいいのですが、 店舗数が増えれば増えるほど見辛くなる問題も孕んでいます。 ただでさえ1つで画面占有の6割以上です。 これを2つ、3つと増やした場合はユーザビリティの悪化に繋がります。 なので残念ながらこれ以上の対応は思いつかないということで、簡易的なお気に入り機能として使っていただけると幸いです。 終わりに LINE Messaging APIを使うことでフロントの開発から解放されます。 LINEという身近なツールを使うことでインストールなどの手間もなく、これがあると便利だなってものを簡単に制作することができます。 ぜひ皆さんもLINE Bot開発をしてみてください。 ここまで読んでいただきありがとうございました。
- 投稿日:2021-08-09T15:43:56+09:00
【個人開発】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(①)
LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。 完成形としては以下の通りです。 アーキテクチャ 今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, 「Amazon DynamoDB」の3つを使用してアプリを作成します。 また、プロビジョニングやデプロイに関してはAWS SAM(Serverless Application Model)を使用します。 対象読者 ・ Node.jsを勉強中の方 ・ TypeScriptを勉強中の方 ・ インフラ初心者の方 ・ ポートフォリオのデプロイ先をどうするか迷っている方 作成の難易度は低めです。 理由は、必要なパッケージも少ないため要件が複雑ではないからです。 また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。 記事 今回は2つの記事に分かれています。 お店の検索を行うところまでを今回の記事で行っています。 お気に入り店の登録や解除などを次の記事で行います。 どのようなアプリか 皆さんは、どのようにして飲食店を探しますか? 私は、食べログなどのグルメサイトを使わずに Google Mapで探します。 以前食べログで「星 3.8 問題」がありました。 これだけではなく、食べログで見つけた行ったお店がイマイチだったこともあり、 グルメサイトはお店を探す場所ではなく、お店を予約するためのサイトと私は割り切りました。 電話が苦手な自分としては、まだまだ飲食店で独自の予約サイトを持っている企業も少ないので、食べログやホットペッパーで予約が可能なのはすごく助かっています。 Google Mapでお店を探すのもなかなか手間がかかるので、今回はGoogle Mapを使って近くの名店を10個教えてくれるアプリを作成しました。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの流れ クライアント LINE Messaging API(バックエンド) ①「お店を探す」をタップ ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 ③ 現在地を送る ④「車か徒歩どちらですか?」というメッセージを送る ⑤ 車か徒歩を選択 ⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内) ⑦ 必要なデータのみにする ⑧ 評価順に並び替えて上位 10 店舗にする ⑨ Flex Message を作成する ⑩ お店の情報を Flex Message で送る ハンズオン! 前提 初めてAWSを使う方に対しての注意です。 ルートユーザーで行うのはよろしくないので、全ての権限を与えたAdministratorユーザーを作っておいてください。 公式サイトはこちらです。 文章は辛いよって方は、初学者のハンズオン動画があるのでこちらからどうぞ。 sam initを実行する ゼロから書いていってもいいのですが、初めての方はまずはsam initを使いましょう。 以下のように選択していってください。 ターミナル $ sam init Which template source would you like to use? 1 - AWS Quick Start Templates 2 - Custom Template Location Choice: 1 What package type would you like to use? 1 - Zip (artifact is a zip uploaded to S3) 2 - Image (artifact is an image uploaded to an ECR image repository) Package type: 1 Which runtime would you like to use? 1 - nodejs14.x 2 - python3.8 3 - ruby2.7 4 - go1.x 5 - java11 6 - dotnetcore3.1 7 - nodejs12.x 8 - nodejs10.x 9 - python3.7 10 - python3.6 11 - python2.7 12 - ruby2.5 13 - java8.al2 14 - java8 15 - dotnetcore2.1 Runtime: 7 Project name [sam-app]: Gourmet AWS quick start application templates: 1 - Hello World Example 2 - Step Functions Sample App (Stock Trader) 3 - Quick Start: From Scratch 4 - Quick Start: Scheduled Events 5 - Quick Start: S3 6 - Quick Start: SNS 7 - Quick Start: SQS 8 - Quick Start: App Backend using TypeScript 9 - Quick Start: Web Backend Template selection: 1 ここまでできれば作成されます。 このような構成になっていればOKです。 .Gourmet ├── events/ │ ├── event.json ├── hello-world/ │ ├── tests │ │ └── integration │ │ │ └── test-api-gateway.js │ │ └── unit │ │ │ └── test-handler.js │ ├── .npmignore │ ├── app.js │ ├── package.json ├── .gitignore ├── README.md ├── template.yaml 必要ないファイルなどがあるのでそれを削除していきましょう。 .Gourmet ├── hello-world/ │ ├── app.js ├── .gitignore ├── README.md ├── template.yaml また、ディレクトリ名やファイル名を変えましょう。 .Gourmet ├── api/ │ ├── index.js ├── .gitignore ├── README.md ├── template.yaml 次は、template.yamlを修正して、SAMの実行をしてみたいところですが、一旦後回しにします。 先にTypeScriptなどのパッケージを入れ、ディレクトリ構造を明確にした後の方が理解しやすいので。。 ということでパッケージを入れていきましょう。 package.jsonの作成 以下のコマンドを入力してください。 これで、package.jsonの作成が完了します。 ターミナル $ npm init -y 必要なパッケージのインストール dependencies dependenciesはすべてのステージで使用するパッケージです。 今回使用するパッケージは以下の4つです。 ・@line/bot-sdk ・aws-sdk ・axios 以下のコマンドを入力してください。 これで全てのパッケージがインストールされます。 ターミナル $ npm install @line/bot-sdk aws-sdk axios --save devDependencies devDependenciesはコーディングステージのみで使用するパッケージです。 今回使用するパッケージは以下の5つです。 ・typescript ・@types/node ・ts-node ・rimraf ・npm-run-all 以下のコマンドを入力してください。 これで全てのパッケージがインストールされます。 ターミナル $ npm install -D typescript @types/node ts-node rimraf npm-run-all package.jsonにコマンドの設定を行う npm run buildでコンパイルを行います。 package.json { "scripts": { "clean": "rimraf dist", "tsc": "tsc", "build": "npm-run-all clean tsc" }, } tsconfig.jsonの作成 以下のコマンドを実行しTypeScriptの初期設定を行います。 ターミナル $ npx tsc --init それでは、作成されたtsconfig.jsonの上書きをしていきます。 tsconfig.json { "compilerOptions": { "target": "ES2018", "module": "commonjs", "sourceMap": true, "outDir": "./api/dist", "strict": true, "moduleResolution": "node", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["api/src/*"] } 簡単にまとめると、 api/srcディレクトリ以下を対象として、それらをapi/distディレクトリにES2018の書き方でビルドされるという設定です。 tsconfig.jsonに関して詳しく知りたい方は以下のサイトをどうぞ。 また、この辺りで必要ないディレクトリはGithubにpushしたくないので、.gitignoreも作成しておきましょう。 .gitignore node_modules package-lock.json .aws-sam samconfig.toml dist 最終的にはこのようなディレクトリ構成にしましょう。 .Gourmet ├── api/ │ ├── dist(コンパイル後) │ │ └── node_modules(コピーする) │ │ └── package.json(コピーする) │ ├── src(コンパイル前) │ │ └── index.ts ├── node_modules(コピー元) ├── .gitignore ├── package.json(コピー元) ├── package-lock.json ├── README.md ├── template.yaml ├── tsconfig.json やるべきことは以下の2つです。 ①distディレクトリを作成する ②distディレクトリに、node_modules, package.jsonをコピーする 次に、template.yamlを書いていきましょう。 SAM Templateを記載する ファイル内にコメントを残しています。 これで大まかには理解できるかと思います。 詳しくは公式サイトを見てください。 template.yaml # AWS CloudFormationテンプレートのバージョン AWSTemplateFormatVersion: '2010-09-09' # CloudFormationではなくSAMを使うと明記する Transform: AWS::Serverless-2016-10-31 # CloudFormationのスタックの説明文(重要ではないので適当でOK) Description: > LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです Globals: # Lambda関数のタイムアウト値(3秒に設定) Function: Timeout: 3 Resources: # API Gateway GourmetAPI: # Typeを指定する(今回はAPI Gateway) Type: AWS::Serverless::Api Properties: # ステージ名(APIのURLの最後にこのステージ名が付与されます) StageName: v1 # Lambda GourmetFunction: # Typeを指定する(今回はLambda) Type: AWS::Serverless::Function Properties: # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する) CodeUri: api/dist # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります) Handler: index.handler # どの言語とどのバージョンを使用するか Runtime: nodejs12.x # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限を付与) Policies: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess # この関数をトリガーするイベントを指定します Events: # API Gateway GourmetAPI: Type: Api Properties: # どのAPIを使用するか(!Refは値の参照に使用します) RestApiId: !Ref GourmetAPI # URL Path: / # POSTメソッド Method: post Outputs: GourmetAPI: Description: 'API Gateway' Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1' GourmetFunction: Description: 'Lambda' Value: !GetAtt GourmetFunction.Arn GourmetFunctionIamRole: Description: 'IAM Role' Value: !GetAtt GourmetFunctionRole.Arn LINE Developersにアカウントを作成する LINE Developersにアクセスして、「ログイン」ボタンをクリックしてください。 その後諸々入力してもらったら以下のように作成できるかと思います。 注意事項としては、今回Messaging APIとなるので、チャネルの種類を間違えた方は修正してください。 チャネルシークレットとチャネルアクセストークンが必要になるのでこの2つを発行します。 これで必要な環境変数は取得できました。 それでは、これをSSMを使ってLambda内で使えるようにしていきましょう。 SSMパラメータストアで環境変数を設定 なぜSSMパラメータストアを使うのか? SAMのLambda設定にも、環境変数の項目はあります。 しかし、2点問題点があります。 ①Lambdaの環境変数の変更をしたいとき、Lambdaのバージョンも新規発行をしなければならない ②Lambdaのバージョンとエイリアスを紐付けて管理をするとき、もし環境変数にリリース先環境別の値をセットしていると、リリース時に手動で環境変数の変更をしなければならないケースが発生する 簡単にまとめると、「リアルタイムで反映できないし、人為的なミスのリスクもあるよ」ということです。 SSMパラメータストアで値を管理すると以下の3点のメリットがあります。 ①Lambdaの環境変数の管理が不要 ②Lambdaも含めた値関連情報を一元管理できる ③Lambda外部からリアルタイムに環境変数を変更制御できる ということで、SSMパラメータストアを使用しましょう。 みんな大好きクラスメソッドの記事にやり方が書いてあります。 こちらの記事が完璧なのでこちらを見てやってみてください。 私は以下のように命名して作成しました。 SSMパラメータが取得できているかconsole.logで検証 api/src/index.ts // import import aws from 'aws-sdk'; // SSM const ssm = new aws.SSM(); exports.handler = async (event: any, context: any) => { const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; console.log('channelAccessToken: ' + channelAccessToken); }; これをコンパイルしてデプロイしていきましょう。 ターミナル // コンパイル $ npm run build // ビルド $ sam build // デプロイ $ sam deploy --guided Configuring SAM deploy ====================== Looking for samconfig.toml : Not found Setting default arguments for 'sam deploy' ========================================= // CloudFormation スタック名の指定 Stack Name [sam-app]: Gourmet // リージョンの指定 AWS Region [us-east-1]: ap-northeast-1 // デプロイ前にCloudformationの変更セットを確認するか #Shows you resources changes to be deployed and require a 'Y' to initiate deploy Confirm changes before deploy [y/N]: y // SAM CLI に IAM ロールの作成を許可するか(CAPABILITY_IAM) #SAM needs permission to be able to create roles to connect to the resources in your template Allow SAM CLI IAM role creation [Y/n]: y // API イベントタイプの関数に認証が含まれていない場合、警告される HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: y // この設定を samconfig.toml として保存するか Save arguments to samconfig.toml [Y/n]: y これでデプロイが完了します。 では、API GatewayのURLを確認しましょう。 Webhook URLの登録 先ほどAPI Gatewayで作成したhttpsのURLをコピーしてください。 これをLINE DevelopersのWebhookに設定します。 それではSSMパラメータが正しく取得できているか確認しましょう。 CloudWatchで確認しましょう! 取得できていますね! これで準備は完了です。 ここから飲食店検索の仕組みを作っていきましょう! アプリの流れ クライアント LINE Messaging API(バックエンド) ①「お店を探す」をタップ ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 ③ 現在地を送る ④「車か徒歩どちらですか?」というメッセージを送る ⑤ 車か徒歩を選択 ⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内) ⑦ 必要なデータのみにする ⑧ 評価順に並び替えて上位 10 店舗にする ⑨ Flex Message を作成する ⑩ お店の情報を Flex Message で送る ①「お店を探す」をタップ こちらに関してはクライアント側の操作なので作業することはありません。 ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 「現在地を送る」ためのボタンメッセージ api/src/Common/TemplateMessage/YourLocation.ts // Load the package import { TemplateMessage } from '@line/bot-sdk'; export const yourLocationTemplate = (): Promise<TemplateMessage> => { return new Promise((resolve, reject) => { const params: TemplateMessage = { type: 'template', altText: '現在地を送ってください!', template: { type: 'buttons', text: '今日はどこでご飯を食べる?', actions: [ { type: 'uri', label: '現在地を送る', uri: 'https://line.me/R/nv/location/', }, ], }, }; resolve(params); }); }; ちなみに以下のURLですが、LINEで利用できるURLスキームというもので位置情報を送れるものです。 https://line.me/R/nv/location/ 詳しくは以下をご確認ください。 エラーメッセージ api/src/Common/TemplateMessage/Error.ts // Load the package import { TextMessage } from '@line/bot-sdk'; export const errorTemplate = (): Promise<TextMessage> => { return new Promise((resolve, reject) => { const params: TextMessage = { type: 'text', text: 'ごめんなさい、このメッセージには対応していません', }; resolve(params); }); }; メッセージの送信 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; ③ 現在地を送る こちらに関してもクライアント側の操作なので作業することはありません。 ④「車か徒歩どちらですか?」というメッセージを送る LINE Messaging APIにキャッシュの機能などはありません。 なので、③の「現在地を送る」のデータはどこかに格納しないと値が消えてしまいます。 ということで、今回はサーバーレスと相性の良い「DynamoDB」を使用します。 DynamoDB 以下のテーブルを作成します。 PK K K K user_id latitude longitude is_car ユーザー ID 緯度 経度 車か徒歩か それぞれのデータ取得方法 ユーザーIDは、event.source.userIdから取得できます。 緯度、経度は、【クライアント】③ 現在地を送るから取得できます。 車か徒歩かは、【クライアント】⑤ 車か徒歩を選択から取得できます。 SAMテンプレートにDynamoDBの記載を行う template.yaml # AWS CloudFormationテンプレートのバージョン AWSTemplateFormatVersion: '2010-09-09' # CloudFormationではなくSAMを使うと明記する Transform: AWS::Serverless-2016-10-31 # CloudFormationのスタックの説明文(重要ではないので適当でOK) Description: > LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです Globals: # Lambda関数のタイムアウト値(3秒に設定) Function: Timeout: 3 Resources: # API Gateway GourmetAPI: # Typeを指定する(今回はAPI Gateway) Type: AWS::Serverless::Api Properties: # ステージ名(APIのURLの最後にこのステージ名が付与されます) StageName: v1 + # DynamoDB + GourmetDynamoDB: + # Typeを指定する(今回はDynamoDB) + Type: AWS::Serverless::SimpleTable + Properties: + # テーブルの名前 + TableName: Gourmets + # プライマリキーの設定(名前とプライマリキーのタイプ) + PrimaryKey: + Name: user_id + Type: String + # プロビジョニングされたキャパシティの設定(今回の要件では最小の1でOK) + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 # Lambda GourmetFunction: # Typeを指定する(今回はLambda) Type: AWS::Serverless::Function Properties: # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する) CodeUri: api/dist # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります) Handler: index.handler # どの言語とどのバージョンを使用するか Runtime: nodejs12.x # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限とDynamoDBのフルアクセス権限を付与) Policies: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess + - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess # この関数をトリガーするイベントを指定します Events: # API Gateway GourmetAPI: Type: Api Properties: # どのAPIを使用するか(!Refは値の参照に使用します) RestApiId: !Ref GourmetAPI # URL Path: / # POSTメソッド Method: post Outputs: GourmetAPI: Description: 'API Gateway' Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1' GourmetFunction: Description: 'Lambda' Value: !GetAtt GourmetFunction.Arn GourmetFunctionIamRole: Description: 'IAM Role' Value: !GetAtt GourmetFunctionRole.Arn 現在地が送信されたらDynamoDBのuser_id, latitude, longitudeが入力されるようにする 今回はDynamoDBに新規のレコードを追加します。 新規追加はputを使用します。 api/src/Common/Database/PutLocation.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putLocation = (userId: string | undefined, latitude: string, longitude: string) => { return new Promise((resolve, reject) => { const params = { Item: { user_id: userId, latitude: latitude, longitude: longitude, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; この関数をindex.tsで読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; // Database + import { putLocation } from './Common/Database/PutLocation'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); + await actionIsCar(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; + const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { + try { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'location') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const latitude: string = String(event.message.latitude); + const longitude: string = String(event.message.longitude); + + // Register userId, latitude, and longitude in DynamoDB + await putLocation(userId, latitude, longitude); + } catch (err) { + console.log(err); + } + }; これでDynamoDBへの登録が完了です。 次にメッセージを作成しましょう。 api/src/Common/TemplateMessage/IsCar.ts // Load the package import { TemplateMessage } from '@line/bot-sdk'; export const isCarTemplate = (): Promise<TemplateMessage> => { return new Promise((resolve, reject) => { const params: TemplateMessage = { type: 'template', altText: 'あなたの移動手段は?', template: { type: 'confirm', text: 'あなたの移動手段は?', actions: [ { type: 'message', label: '車', text: '車', }, { type: 'message', label: '徒歩', text: '徒歩', }, ], }, }; resolve(params); }); }; 最後にこちらの関数をindex.tsに読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; + import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); + } else if (text === '車' || text === '徒歩') { + return; + } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); + // modules + const isCar = await isCarTemplate(); + // Send a two-choice question + await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; ⑤ 車か徒歩を選択 こちらに関してもクライアント側の操作なので作業することはありません。 ⑥ お店の配列を作成する 車の場合現在地から 14km以内、徒歩の場合 0.8km以内で検索することとします。 車は20分程度、徒歩は10分程度で着く範囲を検索対象としています。 移動手段が送信されたらDynamoDBのis_carが入力されるようにする 今回はDynamoDBにuser_idをキーとして、レコードを更新します。 更新はupdateを使用します。 ではやっていきましょう。 api/src/Common/Database/UpdateIsCar.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const updateIsCar = (userId: string | undefined, isCar: string) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets', Key: { user_id: userId, }, UpdateExpression: 'SET is_car = :i', ExpressionAttributeValues: { ':i': isCar, }, }; docClient.update(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; このDB処理をindex.tsで読み込みます。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; + import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; + const actionFlexMessage = async (client: Client, event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'text') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const isCar = event.message.text; + + // Perform a conditional branch + if (isCar === '車' || isCar === '徒歩') { + // Register userId, isCar in DynamoDB + await updateIsCar(userId, isCar); + } else { + return; + } + } catch (err) { + console.log(err); + } + }; お店の配列を作成するまでのステップ 1. DynamoDBのデータを取得する api/src/Common/TemplateMessage/Gourmet/GetDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const getDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets', Key: { user_id: userId, }, }; docClient.get(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; 2. Google Map APIを取得して、SSMパラメーターストアに登録する Google MapのAPIを取得しましょう。 まずはGCPのコンソール画面に入って下さい。 コンソールに入ったらプロジェクトを作成しましょう! 私は、LINE-Node-TypeScript-Gourmetで作成しました。 では、ライブラリを有効化しましょう! 使うライブラリは2つです。 Map JavaScript API Places API お店検索をするAPIは「Places API」ですが、 JavaScriptから呼び出すために「Map JavaScript API」が必要となります。 ここまでできたら次にAPIを作成しましょう。 これからの開発はこちらのAPIキーを使います。 セキュリティ的には制限をつけたほうがいいのですが、今回はつけずに行います。 上記の説明でわからなければ以下のサイトを参考にされて下さい。 では取得したAPIをSSMパラメーターストアに登録しましょう。 方法は以下の通りです。 私はこのように命名しました。 ではこの値を関数内で使えるようにしましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; + const LINE_GOURMET_GOOGLE_MAP_API = { + Name: 'LINE_GOURMET_GOOGLE_MAP_API', + WithDecryption: false, + }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); + const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; + const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; + const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); } else { return; } } catch (err) { console.log(err); } }; 3. お店の配列を作成する 近隣のお店を調べるので、Place SearchのNearby Search requestsを使います。 ここが正直イマイチなコードかもしれません。 setTimeoutを頻発しているからです。 Nearby Search requestsは20店舗しか取り出すことができないのですが、 pagetokenを使用することで60店舗取り出すことができます。 このpagetokenを使って再度呼び出しを行うのですが、その時に待ち時間が必要になります。 最初は、async, awaitの非同期で対応できると思っていたのですが、この待ち時間だけでは足りないようでsetTimeoutが必要になりました。 こちらはコードがイマイチなので、対応を考えて他の方法があれば修正いたします。 ここはこんなコードの書き方もあるんだ程度にしていただけますと幸いです。 api/src/Common/TemplateMessage/Gourmet/GetGourmetInfo.ts // Load the package import axios, { AxiosResponse } from 'axios'; // Load the module import { getDatabaseInfo } from './GetDatabaseInfo'; export const getGourmetInfo = async (user_id: string | undefined, googleMapApi: string) => { return new Promise(async (resolve, reject) => { // modules getDatabaseInfo const data: any = await getDatabaseInfo(user_id); const isCar = data.Item.is_car; const latitude = data.Item.latitude; const longitude = data.Item.longitude; // Bifurcate the radius value depending on whether you are driving or walking let radius = 0; if (isCar === '車') { radius = 1400; } else { radius = 800; } let gourmetArray: any[] = []; const url = `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${latitude},${longitude}&radius=${radius}&type=restaurant&key=${googleMapApi}&language=ja`; new Promise(async (resolve) => { const gourmets: AxiosResponse<any> = await axios.get(url); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); const pageToken = gourmets.data.next_page_token; resolve(pageToken); }) .then((value) => { return new Promise((resolve) => { setTimeout(async () => { const addTokenUrl = `${url}&pagetoken=${value}`; const gourmets = await axios.get(addTokenUrl); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); const pageToken = gourmets.data.next_page_token; resolve(pageToken); }, 2000); }); }) .then((value) => { setTimeout(async () => { const addTokenUrl = `${url}&pagetoken=${value}`; const gourmets = await axios.get(addTokenUrl); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); }, 2000); }); setTimeout(() => { resolve(gourmetArray); }, 8000); }); }; ⑦ 必要なデータのみにする 使うデータは以下の通りです。 必要なデータ 理由 geometry_location_lat 店舗案内のURLで使うため geometry_location_lng 店舗案内のURLで使うため name Flex Message内と店舗詳細のURLで使うため photo_reference 店舗写真を生成するために使う rating Flex Message内で使うため vicinity 店舗詳細のURLで使うため 店舗詳細と店舗案内、店舗写真のURLはこの後解説します。 ということで必要なデータのみを抜き出して配列を再生成しましょう。 api/src/Common/TemplateMessage/Gourmet/FormatGourmetArray.ts // Load the module import { getGourmetInfo } from './GetGourmetInfo'; // types import { RequiredGourmetArray } from './types/FormatGourmetArray.type'; export const formatGourmetArray = async ( user_id: string | undefined, googleMapApi: string ): Promise<RequiredGourmetArray> => { return new Promise(async (resolve, reject) => { // modules getGourmetInfo const gourmetInfo: any = await getGourmetInfo(user_id, googleMapApi); // Extract only the data you need const sufficientGourmetArray: any = gourmetInfo.filter( (gourmet: any) => gourmet.photos !== undefined || null ); // Format the data as required const requiredGourmetArray: RequiredGourmetArray = sufficientGourmetArray.map( (gourmet: any) => { return { geometry_location_lat: gourmet.geometry.location.lat, geometry_location_lng: gourmet.geometry.location.lng, name: gourmet.name, photo_reference: gourmet.photos[0].photo_reference, rating: gourmet.rating, vicinity: gourmet.vicinity, }; } ); resolve(requiredGourmetArray); }); }; 上記で、RequiredGourmetArrayという型を使用しているので型定義ファイルを作ります。 api/src/Common/TemplateMessage/Gourmet/types/FormatGourmetArray.type.ts export type RequiredGourmetArray = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }[]; ⑧ 評価順に並び替えて上位10店舗にする sortで並び替えて、sliceで新たな配列を作ってあげましょう! api/src/Common/TemplateMessage/Gourmet/SortRatingGourmetArray.ts // Load the module import { formatGourmetArray } from './FormatGourmetArray'; // types import { GourmetData, GourmetDataArray } from './types/SortRatingGourmetArray.type'; export const sortRatingGourmetArray = async ( user_id: string | undefined, googleMapApi: string ): Promise<GourmetDataArray> => { return new Promise(async (resolve, reject) => { try { // modules formatGourmetArray const gourmetArray: GourmetDataArray = await formatGourmetArray(user_id, googleMapApi); // Sort by rating gourmetArray.sort((a: GourmetData, b: GourmetData) => b.rating - a.rating); // narrow it down to 10 stores. const sortGourmetArray: GourmetDataArray = gourmetArray.slice(0, 10); console.log(sortGourmetArray); resolve(sortGourmetArray); } catch (err) { reject(err); } }); }; 型定義を行いましょう。 api/src/Common/TemplateMessage/Gourmet/types/SortRatingGourmetArray.type.ts export type GourmetData = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }; export type GourmetDataArray = GourmetData[]; ⑨ Flex Messageを作成する ⑦で説明した必要なデータについて解説します。 必要なデータ 理由 geometry_location_lat 店舗案内のURLで使うため geometry_location_lng 店舗案内のURLで使うため name Flex Message内と店舗詳細のURLで使うため photo_reference 店舗写真を生成するために使う rating Flex Message内で使うため vicinity 店舗詳細のURLで使うため nameとratingはFlex Message内で使います。 店舗詳細に関してですが、こちらのURLは以下となります。 https://maps.google.co.jp/maps?q=${店舗名}${住所}&z=15&iwloc=A 店舗案内に関しては以下のURLとなります。 https://www.google.com/maps/dir/?api=1&destination=${緯度},${経度} 店舗写真に関しては以下のURLとなります。 https://maps.googleapis.com/maps/api/place/photo?maxwidth=${任意の幅}&photoreference=${photo_reference}&key=${Google_API} ということで、Flex Message内でこれらのURLを生成していけば完成です。 やっていきましょう! api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { sortRatingGourmetArray } from './SortRatingGourmetArray'; // types import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type'; export const createFlexMessage = async ( user_id: string | undefined, googleMapApi: string ): Promise<FlexMessage | undefined> => { return new Promise(async (resolve, reject) => { try { // modules sortRatingGourmetArray const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray( user_id, googleMapApi ); // FlexMessage const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => { // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`; const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: photoURL, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: gourmet.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${gourmet.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: storeDetailsURL, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: storeRoutingURL, }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: '近隣の美味しいお店10店ご紹介', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 型定義を行いましょう。 api/src/Common/TemplateMessage/Gourmet/types/CreateFlexMessage.type.ts export type Gourmet = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }; export type RatingGourmetArray = Gourmet[]; ⑩ お店の情報をFlex Messageで送る api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; + import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); + const flexMessage = await createFlexMessage(userId, googleMapApi); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; デプロイ まずは、npm run buildでコンパイルしましょう。 ターミナル $ npm run build コンパイルされた後は、ビルドしてデプロイしていきましょう。 ターミナル // ビルド $ sam build // デプロイ $ sam deploy --guided DynamoDBも確認しましょう。 しっかり保存されていますね! 最後に 追加する要件として、今後はお気に入りのお店を登録する機能なども足していこうと思います。 ここまで読んでいただきありがとうございました!
- 投稿日:2021-08-09T15:43:56+09:00
【個人開発/LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(①)
LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。 完成形としては以下の通りです。 アーキテクチャ 今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, 「Amazon DynamoDB」の3つを使用してアプリを作成します。 また、プロビジョニングやデプロイに関してはAWS SAM(Serverless Application Model)を使用します。 対象読者 ・ Node.jsを勉強中の方 ・ TypeScriptを勉強中の方 ・ インフラ初心者の方 ・ ポートフォリオのデプロイ先をどうするか迷っている方 作成の難易度は低めです。 理由は、必要なパッケージも少ないため要件が複雑ではないからです。 また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。 記事 今回は2つの記事に分かれています。 お店の検索を行うところまでを今回の記事で行っています。 お気に入り店の登録や解除などを次の記事で行います。 どのようなアプリか 皆さんは、どのようにして飲食店を探しますか? 私は、食べログなどのグルメサイトを使わずに Google Mapで探します。 以前食べログで「星 3.8 問題」がありました。 これだけではなく、食べログで見つけた行ったお店がイマイチだったこともあり、 グルメサイトはお店を探す場所ではなく、お店を予約するためのサイトと私は割り切りました。 電話が苦手な自分としては、まだまだ飲食店で独自の予約サイトを持っている企業も少ないので、食べログやホットペッパーで予約が可能なのはすごく助かっています。 Google Mapでお店を探すのもなかなか手間がかかるので、今回はGoogle Mapを使って近くの名店を10個教えてくれるアプリを作成しました。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの流れ クライアント LINE Messaging API(バックエンド) ①「お店を探す」をタップ ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 ③ 現在地を送る ④「車か徒歩どちらですか?」というメッセージを送る ⑤ 車か徒歩を選択 ⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内) ⑦ 必要なデータのみにする ⑧ 評価順に並び替えて上位 10 店舗にする ⑨ Flex Message を作成する ⑩ お店の情報を Flex Message で送る ハンズオン! 前提 初めてAWSを使う方に対しての注意です。 ルートユーザーで行うのはよろしくないので、全ての権限を与えたAdministratorユーザーを作っておいてください。 公式サイトはこちらです。 文章は辛いよって方は、初学者のハンズオン動画があるのでこちらからどうぞ。 sam initを実行する ゼロから書いていってもいいのですが、初めての方はまずはsam initを使いましょう。 以下のように選択していってください。 ターミナル $ sam init Which template source would you like to use? 1 - AWS Quick Start Templates 2 - Custom Template Location Choice: 1 What package type would you like to use? 1 - Zip (artifact is a zip uploaded to S3) 2 - Image (artifact is an image uploaded to an ECR image repository) Package type: 1 Which runtime would you like to use? 1 - nodejs14.x 2 - python3.8 3 - ruby2.7 4 - go1.x 5 - java11 6 - dotnetcore3.1 7 - nodejs12.x 8 - nodejs10.x 9 - python3.7 10 - python3.6 11 - python2.7 12 - ruby2.5 13 - java8.al2 14 - java8 15 - dotnetcore2.1 Runtime: 7 Project name [sam-app]: Gourmet AWS quick start application templates: 1 - Hello World Example 2 - Step Functions Sample App (Stock Trader) 3 - Quick Start: From Scratch 4 - Quick Start: Scheduled Events 5 - Quick Start: S3 6 - Quick Start: SNS 7 - Quick Start: SQS 8 - Quick Start: App Backend using TypeScript 9 - Quick Start: Web Backend Template selection: 1 ここまでできれば作成されます。 このような構成になっていればOKです。 .Gourmet ├── events/ │ ├── event.json ├── hello-world/ │ ├── tests │ │ └── integration │ │ │ └── test-api-gateway.js │ │ └── unit │ │ │ └── test-handler.js │ ├── .npmignore │ ├── app.js │ ├── package.json ├── .gitignore ├── README.md ├── template.yaml 必要ないファイルなどがあるのでそれを削除していきましょう。 .Gourmet ├── hello-world/ │ ├── app.js ├── .gitignore ├── README.md ├── template.yaml また、ディレクトリ名やファイル名を変えましょう。 .Gourmet ├── api/ │ ├── index.js ├── .gitignore ├── README.md ├── template.yaml 次は、template.yamlを修正して、SAMの実行をしてみたいところですが、一旦後回しにします。 先にTypeScriptなどのパッケージを入れ、ディレクトリ構造を明確にした後の方が理解しやすいので。。 ということでパッケージを入れていきましょう。 package.jsonの作成 以下のコマンドを入力してください。 これで、package.jsonの作成が完了します。 ターミナル $ npm init -y 必要なパッケージのインストール dependencies dependenciesはすべてのステージで使用するパッケージです。 今回使用するパッケージは以下の4つです。 ・@line/bot-sdk ・aws-sdk ・axios 以下のコマンドを入力してください。 これで全てのパッケージがインストールされます。 ターミナル $ npm install @line/bot-sdk aws-sdk axios --save devDependencies devDependenciesはコーディングステージのみで使用するパッケージです。 今回使用するパッケージは以下の5つです。 ・typescript ・@types/node ・ts-node ・rimraf ・npm-run-all 以下のコマンドを入力してください。 これで全てのパッケージがインストールされます。 ターミナル $ npm install -D typescript @types/node ts-node rimraf npm-run-all package.jsonにコマンドの設定を行う npm run buildでコンパイルを行います。 package.json { "scripts": { "clean": "rimraf dist", "tsc": "tsc", "build": "npm-run-all clean tsc" }, } tsconfig.jsonの作成 以下のコマンドを実行しTypeScriptの初期設定を行います。 ターミナル $ npx tsc --init それでは、作成されたtsconfig.jsonの上書きをしていきます。 tsconfig.json { "compilerOptions": { "target": "ES2018", "module": "commonjs", "sourceMap": true, "outDir": "./api/dist", "strict": true, "moduleResolution": "node", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["api/src/*"] } 簡単にまとめると、 api/srcディレクトリ以下を対象として、それらをapi/distディレクトリにES2018の書き方でビルドされるという設定です。 tsconfig.jsonに関して詳しく知りたい方は以下のサイトをどうぞ。 また、この辺りで必要ないディレクトリはGithubにpushしたくないので、.gitignoreも作成しておきましょう。 .gitignore node_modules package-lock.json .aws-sam samconfig.toml dist 最終的にはこのようなディレクトリ構成にしましょう。 .Gourmet ├── api/ │ ├── dist(コンパイル後) │ │ └── node_modules(コピーする) │ │ └── package.json(コピーする) │ ├── src(コンパイル前) │ │ └── index.ts ├── node_modules(コピー元) ├── .gitignore ├── package.json(コピー元) ├── package-lock.json ├── README.md ├── template.yaml ├── tsconfig.json やるべきことは以下の2つです。 ①distディレクトリを作成する ②distディレクトリに、node_modules, package.jsonをコピーする 次に、template.yamlを書いていきましょう。 SAM Templateを記載する ファイル内にコメントを残しています。 これで大まかには理解できるかと思います。 詳しくは公式サイトを見てください。 template.yaml # AWS CloudFormationテンプレートのバージョン AWSTemplateFormatVersion: '2010-09-09' # CloudFormationではなくSAMを使うと明記する Transform: AWS::Serverless-2016-10-31 # CloudFormationのスタックの説明文(重要ではないので適当でOK) Description: > LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです Globals: # Lambda関数のタイムアウト値(3秒に設定) Function: Timeout: 3 Resources: # API Gateway GourmetAPI: # Typeを指定する(今回はAPI Gateway) Type: AWS::Serverless::Api Properties: # ステージ名(APIのURLの最後にこのステージ名が付与されます) StageName: v1 # Lambda GourmetFunction: # Typeを指定する(今回はLambda) Type: AWS::Serverless::Function Properties: # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する) CodeUri: api/dist # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります) Handler: index.handler # どの言語とどのバージョンを使用するか Runtime: nodejs12.x # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限を付与) Policies: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess # この関数をトリガーするイベントを指定します Events: # API Gateway GourmetAPI: Type: Api Properties: # どのAPIを使用するか(!Refは値の参照に使用します) RestApiId: !Ref GourmetAPI # URL Path: / # POSTメソッド Method: post Outputs: GourmetAPI: Description: 'API Gateway' Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1' GourmetFunction: Description: 'Lambda' Value: !GetAtt GourmetFunction.Arn GourmetFunctionIamRole: Description: 'IAM Role' Value: !GetAtt GourmetFunctionRole.Arn LINE Developersにアカウントを作成する LINE Developersにアクセスして、「ログイン」ボタンをクリックしてください。 その後諸々入力してもらったら以下のように作成できるかと思います。 注意事項としては、今回Messaging APIとなるので、チャネルの種類を間違えた方は修正してください。 チャネルシークレットとチャネルアクセストークンが必要になるのでこの2つを発行します。 これで必要な環境変数は取得できました。 それでは、これをSSMを使ってLambda内で使えるようにしていきましょう。 SSMパラメータストアで環境変数を設定 なぜSSMパラメータストアを使うのか? SAMのLambda設定にも、環境変数の項目はあります。 しかし、2点問題点があります。 ①Lambdaの環境変数の変更をしたいとき、Lambdaのバージョンも新規発行をしなければならない ②Lambdaのバージョンとエイリアスを紐付けて管理をするとき、もし環境変数にリリース先環境別の値をセットしていると、リリース時に手動で環境変数の変更をしなければならないケースが発生する 簡単にまとめると、「リアルタイムで反映できないし、人為的なミスのリスクもあるよ」ということです。 SSMパラメータストアで値を管理すると以下の3点のメリットがあります。 ①Lambdaの環境変数の管理が不要 ②Lambdaも含めた値関連情報を一元管理できる ③Lambda外部からリアルタイムに環境変数を変更制御できる ということで、SSMパラメータストアを使用しましょう。 みんな大好きクラスメソッドの記事にやり方が書いてあります。 こちらの記事が完璧なのでこちらを見てやってみてください。 私は以下のように命名して作成しました。 SSMパラメータが取得できているかconsole.logで検証 api/src/index.ts // import import aws from 'aws-sdk'; // SSM const ssm = new aws.SSM(); exports.handler = async (event: any, context: any) => { const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; console.log('channelAccessToken: ' + channelAccessToken); }; これをコンパイルしてデプロイしていきましょう。 ターミナル // コンパイル $ npm run build // ビルド $ sam build // デプロイ $ sam deploy --guided Configuring SAM deploy ====================== Looking for samconfig.toml : Not found Setting default arguments for 'sam deploy' ========================================= // CloudFormation スタック名の指定 Stack Name [sam-app]: Gourmet // リージョンの指定 AWS Region [us-east-1]: ap-northeast-1 // デプロイ前にCloudformationの変更セットを確認するか #Shows you resources changes to be deployed and require a 'Y' to initiate deploy Confirm changes before deploy [y/N]: y // SAM CLI に IAM ロールの作成を許可するか(CAPABILITY_IAM) #SAM needs permission to be able to create roles to connect to the resources in your template Allow SAM CLI IAM role creation [Y/n]: y // API イベントタイプの関数に認証が含まれていない場合、警告される HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: y // この設定を samconfig.toml として保存するか Save arguments to samconfig.toml [Y/n]: y これでデプロイが完了します。 では、API GatewayのURLを確認しましょう。 Webhook URLの登録 先ほどAPI Gatewayで作成したhttpsのURLをコピーしてください。 これをLINE DevelopersのWebhookに設定します。 それではSSMパラメータが正しく取得できているか確認しましょう。 CloudWatchで確認しましょう! 取得できていますね! これで準備は完了です。 ここから飲食店検索の仕組みを作っていきましょう! アプリの流れ クライアント LINE Messaging API(バックエンド) ①「お店を探す」をタップ ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 ③ 現在地を送る ④「車か徒歩どちらですか?」というメッセージを送る ⑤ 車か徒歩を選択 ⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内) ⑦ 必要なデータのみにする ⑧ 評価順に並び替えて上位 10 店舗にする ⑨ Flex Message を作成する ⑩ お店の情報を Flex Message で送る ①「お店を探す」をタップ こちらに関してはクライアント側の操作なので作業することはありません。 ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 「現在地を送る」ためのボタンメッセージ api/src/Common/TemplateMessage/YourLocation.ts // Load the package import { TemplateMessage } from '@line/bot-sdk'; export const yourLocationTemplate = (): Promise<TemplateMessage> => { return new Promise((resolve, reject) => { const params: TemplateMessage = { type: 'template', altText: '現在地を送ってください!', template: { type: 'buttons', text: '今日はどこでご飯を食べる?', actions: [ { type: 'uri', label: '現在地を送る', uri: 'https://line.me/R/nv/location/', }, ], }, }; resolve(params); }); }; ちなみに以下のURLですが、LINEで利用できるURLスキームというもので位置情報を送れるものです。 https://line.me/R/nv/location/ 詳しくは以下をご確認ください。 エラーメッセージ api/src/Common/TemplateMessage/Error.ts // Load the package import { TextMessage } from '@line/bot-sdk'; export const errorTemplate = (): Promise<TextMessage> => { return new Promise((resolve, reject) => { const params: TextMessage = { type: 'text', text: 'ごめんなさい、このメッセージには対応していません', }; resolve(params); }); }; メッセージの送信 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; ③ 現在地を送る こちらに関してもクライアント側の操作なので作業することはありません。 ④「車か徒歩どちらですか?」というメッセージを送る LINE Messaging APIにキャッシュの機能などはありません。 なので、③の「現在地を送る」のデータはどこかに格納しないと値が消えてしまいます。 ということで、今回はサーバーレスと相性の良い「DynamoDB」を使用します。 DynamoDB 以下のテーブルを作成します。 PK K K K user_id latitude longitude is_car ユーザー ID 緯度 経度 車か徒歩か それぞれのデータ取得方法 ユーザーIDは、event.source.userIdから取得できます。 緯度、経度は、【クライアント】③ 現在地を送るから取得できます。 車か徒歩かは、【クライアント】⑤ 車か徒歩を選択から取得できます。 SAMテンプレートにDynamoDBの記載を行う template.yaml # AWS CloudFormationテンプレートのバージョン AWSTemplateFormatVersion: '2010-09-09' # CloudFormationではなくSAMを使うと明記する Transform: AWS::Serverless-2016-10-31 # CloudFormationのスタックの説明文(重要ではないので適当でOK) Description: > LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです Globals: # Lambda関数のタイムアウト値(3秒に設定) Function: Timeout: 3 Resources: # API Gateway GourmetAPI: # Typeを指定する(今回はAPI Gateway) Type: AWS::Serverless::Api Properties: # ステージ名(APIのURLの最後にこのステージ名が付与されます) StageName: v1 + # DynamoDB + GourmetDynamoDB: + # Typeを指定する(今回はDynamoDB) + Type: AWS::Serverless::SimpleTable + Properties: + # テーブルの名前 + TableName: Gourmets + # プライマリキーの設定(名前とプライマリキーのタイプ) + PrimaryKey: + Name: user_id + Type: String + # プロビジョニングされたキャパシティの設定(今回の要件では最小の1でOK) + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 # Lambda GourmetFunction: # Typeを指定する(今回はLambda) Type: AWS::Serverless::Function Properties: # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する) CodeUri: api/dist # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります) Handler: index.handler # どの言語とどのバージョンを使用するか Runtime: nodejs12.x # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限とDynamoDBのフルアクセス権限を付与) Policies: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess + - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess # この関数をトリガーするイベントを指定します Events: # API Gateway GourmetAPI: Type: Api Properties: # どのAPIを使用するか(!Refは値の参照に使用します) RestApiId: !Ref GourmetAPI # URL Path: / # POSTメソッド Method: post Outputs: GourmetAPI: Description: 'API Gateway' Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1' GourmetFunction: Description: 'Lambda' Value: !GetAtt GourmetFunction.Arn GourmetFunctionIamRole: Description: 'IAM Role' Value: !GetAtt GourmetFunctionRole.Arn 現在地が送信されたらDynamoDBのuser_id, latitude, longitudeが入力されるようにする 今回はDynamoDBに新規のレコードを追加します。 新規追加はputを使用します。 api/src/Common/Database/PutLocation.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putLocation = (userId: string | undefined, latitude: string, longitude: string) => { return new Promise((resolve, reject) => { const params = { Item: { user_id: userId, latitude: latitude, longitude: longitude, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; この関数をindex.tsで読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; // Database + import { putLocation } from './Common/Database/PutLocation'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); + await actionIsCar(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; + const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { + try { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'location') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const latitude: string = String(event.message.latitude); + const longitude: string = String(event.message.longitude); + + // Register userId, latitude, and longitude in DynamoDB + await putLocation(userId, latitude, longitude); + } catch (err) { + console.log(err); + } + }; これでDynamoDBへの登録が完了です。 次にメッセージを作成しましょう。 api/src/Common/TemplateMessage/IsCar.ts // Load the package import { TemplateMessage } from '@line/bot-sdk'; export const isCarTemplate = (): Promise<TemplateMessage> => { return new Promise((resolve, reject) => { const params: TemplateMessage = { type: 'template', altText: 'あなたの移動手段は?', template: { type: 'confirm', text: 'あなたの移動手段は?', actions: [ { type: 'message', label: '車', text: '車', }, { type: 'message', label: '徒歩', text: '徒歩', }, ], }, }; resolve(params); }); }; 最後にこちらの関数をindex.tsに読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; + import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); + } else if (text === '車' || text === '徒歩') { + return; + } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); + // modules + const isCar = await isCarTemplate(); + // Send a two-choice question + await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; ⑤ 車か徒歩を選択 こちらに関してもクライアント側の操作なので作業することはありません。 ⑥ お店の配列を作成する 車の場合現在地から 14km以内、徒歩の場合 0.8km以内で検索することとします。 車は20分程度、徒歩は10分程度で着く範囲を検索対象としています。 移動手段が送信されたらDynamoDBのis_carが入力されるようにする 今回はDynamoDBにuser_idをキーとして、レコードを更新します。 更新はupdateを使用します。 ではやっていきましょう。 api/src/Common/Database/UpdateIsCar.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const updateIsCar = (userId: string | undefined, isCar: string) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets', Key: { user_id: userId, }, UpdateExpression: 'SET is_car = :i', ExpressionAttributeValues: { ':i': isCar, }, }; docClient.update(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; このDB処理をindex.tsで読み込みます。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; + import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; + const actionFlexMessage = async (client: Client, event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'text') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const isCar = event.message.text; + + // Perform a conditional branch + if (isCar === '車' || isCar === '徒歩') { + // Register userId, isCar in DynamoDB + await updateIsCar(userId, isCar); + } else { + return; + } + } catch (err) { + console.log(err); + } + }; お店の配列を作成するまでのステップ 1. DynamoDBのデータを取得する api/src/Common/TemplateMessage/Gourmet/GetDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const getDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets', Key: { user_id: userId, }, }; docClient.get(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; 2. Google Map APIを取得して、SSMパラメーターストアに登録する Google MapのAPIを取得しましょう。 まずはGCPのコンソール画面に入って下さい。 コンソールに入ったらプロジェクトを作成しましょう! 私は、LINE-Node-TypeScript-Gourmetで作成しました。 では、ライブラリを有効化しましょう! 使うライブラリは2つです。 Map JavaScript API Places API お店検索をするAPIは「Places API」ですが、 JavaScriptから呼び出すために「Map JavaScript API」が必要となります。 ここまでできたら次にAPIを作成しましょう。 これからの開発はこちらのAPIキーを使います。 セキュリティ的には制限をつけたほうがいいのですが、今回はつけずに行います。 上記の説明でわからなければ以下のサイトを参考にされて下さい。 では取得したAPIをSSMパラメーターストアに登録しましょう。 方法は以下の通りです。 私はこのように命名しました。 ではこの値を関数内で使えるようにしましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; + const LINE_GOURMET_GOOGLE_MAP_API = { + Name: 'LINE_GOURMET_GOOGLE_MAP_API', + WithDecryption: false, + }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); + const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; + const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; + const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); } else { return; } } catch (err) { console.log(err); } }; 3. お店の配列を作成する 近隣のお店を調べるので、Place SearchのNearby Search requestsを使います。 ここが正直イマイチなコードかもしれません。 setTimeoutを頻発しているからです。 Nearby Search requestsは20店舗しか取り出すことができないのですが、 pagetokenを使用することで60店舗取り出すことができます。 このpagetokenを使って再度呼び出しを行うのですが、その時に待ち時間が必要になります。 最初は、async, awaitの非同期で対応できると思っていたのですが、この待ち時間だけでは足りないようでsetTimeoutが必要になりました。 こちらはコードがイマイチなので、対応を考えて他の方法があれば修正いたします。 ここはこんなコードの書き方もあるんだ程度にしていただけますと幸いです。 api/src/Common/TemplateMessage/Gourmet/GetGourmetInfo.ts // Load the package import axios, { AxiosResponse } from 'axios'; // Load the module import { getDatabaseInfo } from './GetDatabaseInfo'; export const getGourmetInfo = async (user_id: string | undefined, googleMapApi: string) => { return new Promise(async (resolve, reject) => { // modules getDatabaseInfo const data: any = await getDatabaseInfo(user_id); const isCar = data.Item.is_car; const latitude = data.Item.latitude; const longitude = data.Item.longitude; // Bifurcate the radius value depending on whether you are driving or walking let radius = 0; if (isCar === '車') { radius = 1400; } else { radius = 800; } let gourmetArray: any[] = []; const url = `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${latitude},${longitude}&radius=${radius}&type=restaurant&key=${googleMapApi}&language=ja`; new Promise(async (resolve) => { const gourmets: AxiosResponse<any> = await axios.get(url); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); const pageToken = gourmets.data.next_page_token; resolve(pageToken); }) .then((value) => { return new Promise((resolve) => { setTimeout(async () => { const addTokenUrl = `${url}&pagetoken=${value}`; const gourmets = await axios.get(addTokenUrl); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); const pageToken = gourmets.data.next_page_token; resolve(pageToken); }, 2000); }); }) .then((value) => { setTimeout(async () => { const addTokenUrl = `${url}&pagetoken=${value}`; const gourmets = await axios.get(addTokenUrl); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); }, 2000); }); setTimeout(() => { resolve(gourmetArray); }, 8000); }); }; ⑦ 必要なデータのみにする 使うデータは以下の通りです。 必要なデータ 理由 geometry_location_lat 店舗案内のURLで使うため geometry_location_lng 店舗案内のURLで使うため name Flex Message内と店舗詳細のURLで使うため photo_reference 店舗写真を生成するために使う rating Flex Message内で使うため vicinity 店舗詳細のURLで使うため 店舗詳細と店舗案内、店舗写真のURLはこの後解説します。 ということで必要なデータのみを抜き出して配列を再生成しましょう。 api/src/Common/TemplateMessage/Gourmet/FormatGourmetArray.ts // Load the module import { getGourmetInfo } from './GetGourmetInfo'; // types import { RequiredGourmetArray } from './types/FormatGourmetArray.type'; export const formatGourmetArray = async ( user_id: string | undefined, googleMapApi: string ): Promise<RequiredGourmetArray> => { return new Promise(async (resolve, reject) => { // modules getGourmetInfo const gourmetInfo: any = await getGourmetInfo(user_id, googleMapApi); // Extract only the data you need const sufficientGourmetArray: any = gourmetInfo.filter( (gourmet: any) => gourmet.photos !== undefined || null ); // Format the data as required const requiredGourmetArray: RequiredGourmetArray = sufficientGourmetArray.map( (gourmet: any) => { return { geometry_location_lat: gourmet.geometry.location.lat, geometry_location_lng: gourmet.geometry.location.lng, name: gourmet.name, photo_reference: gourmet.photos[0].photo_reference, rating: gourmet.rating, vicinity: gourmet.vicinity, }; } ); resolve(requiredGourmetArray); }); }; 上記で、RequiredGourmetArrayという型を使用しているので型定義ファイルを作ります。 api/src/Common/TemplateMessage/Gourmet/types/FormatGourmetArray.type.ts export type RequiredGourmetArray = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }[]; ⑧ 評価順に並び替えて上位10店舗にする sortで並び替えて、sliceで新たな配列を作ってあげましょう! api/src/Common/TemplateMessage/Gourmet/SortRatingGourmetArray.ts // Load the module import { formatGourmetArray } from './FormatGourmetArray'; // types import { GourmetData, GourmetDataArray } from './types/SortRatingGourmetArray.type'; export const sortRatingGourmetArray = async ( user_id: string | undefined, googleMapApi: string ): Promise<GourmetDataArray> => { return new Promise(async (resolve, reject) => { try { // modules formatGourmetArray const gourmetArray: GourmetDataArray = await formatGourmetArray(user_id, googleMapApi); // Sort by rating gourmetArray.sort((a: GourmetData, b: GourmetData) => b.rating - a.rating); // narrow it down to 10 stores. const sortGourmetArray: GourmetDataArray = gourmetArray.slice(0, 10); console.log(sortGourmetArray); resolve(sortGourmetArray); } catch (err) { reject(err); } }); }; 型定義を行いましょう。 api/src/Common/TemplateMessage/Gourmet/types/SortRatingGourmetArray.type.ts export type GourmetData = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }; export type GourmetDataArray = GourmetData[]; ⑨ Flex Messageを作成する ⑦で説明した必要なデータについて解説します。 必要なデータ 理由 geometry_location_lat 店舗案内のURLで使うため geometry_location_lng 店舗案内のURLで使うため name Flex Message内と店舗詳細のURLで使うため photo_reference 店舗写真を生成するために使う rating Flex Message内で使うため vicinity 店舗詳細のURLで使うため nameとratingはFlex Message内で使います。 店舗詳細に関してですが、こちらのURLは以下となります。 https://maps.google.co.jp/maps?q=${店舗名}${住所}&z=15&iwloc=A 店舗案内に関しては以下のURLとなります。 https://www.google.com/maps/dir/?api=1&destination=${緯度},${経度} 店舗写真に関しては以下のURLとなります。 https://maps.googleapis.com/maps/api/place/photo?maxwidth=${任意の幅}&photoreference=${photo_reference}&key=${Google_API} ということで、Flex Message内でこれらのURLを生成していけば完成です。 やっていきましょう! api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { sortRatingGourmetArray } from './SortRatingGourmetArray'; // types import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type'; export const createFlexMessage = async ( user_id: string | undefined, googleMapApi: string ): Promise<FlexMessage | undefined> => { return new Promise(async (resolve, reject) => { try { // modules sortRatingGourmetArray const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray( user_id, googleMapApi ); // FlexMessage const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => { // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`; const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: photoURL, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: gourmet.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${gourmet.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: storeDetailsURL, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: storeRoutingURL, }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: '近隣の美味しいお店10店ご紹介', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 型定義を行いましょう。 api/src/Common/TemplateMessage/Gourmet/types/CreateFlexMessage.type.ts export type Gourmet = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }; export type RatingGourmetArray = Gourmet[]; ⑩ お店の情報をFlex Messageで送る api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; + import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); + const flexMessage = await createFlexMessage(userId, googleMapApi); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; デプロイ まずは、npm run buildでコンパイルしましょう。 ターミナル $ npm run build コンパイルされた後は、ビルドしてデプロイしていきましょう。 ターミナル // ビルド $ sam build // デプロイ $ sam deploy --guided DynamoDBも確認しましょう。 しっかり保存されていますね! 最後に 追加する要件として、今後はお気に入りのお店を登録する機能なども足していこうと思います。 ここまで読んでいただきありがとうございました!
- 投稿日:2021-08-09T15:18:10+09:00
node puppetterでwait系の処理を結構書いたので、メモ
const Util = require('./util'); const wait = { /** * 指定時間処理を止める */ sleep : async (time)=>{ if(time === undefined){ time = process.env.puppet_wait_ms; } return new Promise(resolve => { setTimeout(() => { resolve(); }, time); }); }, /** * 指定のエレメントにselecter要素が表示されるまで待つ * @param ElementHandle elem puppeteerのElementHandle * @param string selecter * @return Promise listで返す */ forChildSelecter:async (elem,selecter) =>{ let isShow = false; let targElms = {}; for(let i = 1 ; i <= process.env.puppet_wait_count; i++){ targElms = await elem.$$(selecter); if(targElms.length > 0){ isShow = true; break; } await wait.sleep(); } if(isShow === false){ throw new Error(process.env.puppet_wait_count+'回待ったけどダメ!'); }else{ return targElms; } }, /** * 指定のエレメントの中のselecter要素が消えるまで待つ * @param ElementHandle elem puppeteerのElementHandle * @param string selecter */ forChildSelecterHide: async(elm,selecter)=>{ let isShow = true; let targElms = {}; for(let i = 1 ; i <= process.env.puppet_wait_count; i++){ targElms = await elm.$$(selecter); if(targElms.length == 0){ isShow = false; break; } await wait.sleep(); } if(isShow === true){ throw new Error(process.env.puppet_wait_count+'回待ったけどダメ!'); }else{ return true; } }, /** * 指定のエレメントの中のselecter要素が作られて、消えるまで待つ * @param ElementHandle elem puppeteerのElementHandle * @param string selecter */ forChildSelecterShowAndHide:async (elm,selecter)=>{ await wait.forChildSelecter(elm,selecter); await wait.forChildSelecterHide(elm,selecter); return true; }, /** * ElementHandleから指定のClassNameが無くなるのを待つ。 * 再帰確認時間はenv.puppet_waitでms */ removeClass : async (elem,targClassName)=>{ let isRemove = false; for(let i = 1 ; i <= process.env.puppet_wait_count; i++){ let handle = await elem.getProperty('className'); let classNames = await handle.jsonValue(); if(classNames.indexOf(targClassName) == -1){ isRemove = true; break; } await wait.sleep(); } if(isRemove === false){ throw new Error(process.env.puppet_wait_count+'回待ったけどダメ!'); }else{ return true; } } }; module.exports = wait;
- 投稿日:2021-08-09T15:17:50+09:00
【JavaScript】ES2021の新機能
2016年あたり以降、JavaScriptの新機能はTC39というところで決められています。 Proposalsを見てみると、箸にも棒にも掛からないものからなんか凄いものまで様々なアイデアが並んでいます。 ここから仕様を洗練していき、ブラウザ上に実装まで行われたものはFinished Proposalsとなります。 ここでは、2021年版であるES2021に取り入れられたものを見てみることにします。 ES2021 Promise.any 複数のPromiseを渡したときに、ひとつでも成功した時点で解決されるPromiseです。 既存のPromise.allは『全てのPromiseが成功したら』でありPromiseのANDですが、Promise.anyはPromiseのORということになります。 const promise1 = xxx; const promise2 = xxx; const promise3 = xxx; Promise.all([promise1, promise2, promise3]) .then(values => { // 全てのPromiseがfulfillされたときに呼ばれる }) .catch(error => { // ひとつでもrejectされたら呼ばれる }); Promise.any([promise1, promise2, promise3]) .then(first => { // ひとつでもfulfillされたら呼ばれる }) .catch(error => { // 全てのPromiseがrejectされたときに呼ばれる }); 複数の宛先にリクエストを投げ、どれかひとつさえ取得できれば残りは不要、みたいなときに使えるでしょう。 anyの実装によって、Promiseの集約メソッドは4種類になります。 const promise1 = xxx; const promise2 = xxx; const promise3 = xxx; Promise.race([promise1, promise2, promise3]) .then(first => { // fulfill/rejectに関わらずひとつでもPromiseが解決したら呼ばれる }); Promise.allSettled([promise1, promise2, promise3]) .then(values => { // fulfill/rejectに関わらず全てのPromiseが解決したら呼ばれる }); 最初に返ってきた値を使うPromise.race、全てが返ってくるまで待つPromise.allSettledと、キャッチするタイミングが異なる関数が幾つか存在します。 それぞれ使うのに適切な場面があるので、うまく使い分けるとよいでしょう。 String.prototype.replaceAll これまでJSには、一致する文字列を全て置換する機能がありませんでした。 replaceは、最初のひとつだけしか置換しません。 'hoge hoge'.replace('hoge', 'fuga'); // "fuga hoge" StackOverflowの質問には4000超というなかなか見たことのないupvoteがついています。 そんなわけでES2021になってようやくreplaceAllが登場しました。 'hoge hoge'.replaceAll('hoge', 'fuga'); // "fuga fuga" 単純にヒットした文字を全て置換します。 正規表現も使用可能ですが、何故かグローバルフラグ/gが必須になります。 'a1212b'.replace(/(\d)(\d)/, '$2$1'); // a2112b 動くが1回だけ 'a1212b'.replace(/(\d)(\d)/g, '$2$1'); // a2121b 動く 'a1212b'.replaceAll(/(\d)(\d)/, '$2$1'); // TypeError: String.prototype.replaceAll called with a non-global RegExp argument 'a1212b'.replaceAll(/(\d)(\d)/g, '$2$1'); // a2121b 動く 実はグローバルフラグを付けると通常のreplaceでも全置換になります。 なので、これまでも正規表現を使えば全置換は可能だったのですが、単純な文字列置換にわざわざ正規表現を持ち出したくはないですよね。 実際プロポーザルの動機には「正規表現にすれば全置換できるけどエスケープしたりしないといけないのでつらい」的なことが書いてありました。 replaceとreplaceAllで正規表現を使ったときの動作が微妙に異なるのはなんかわかりにくいですが、単純な文字列置換が主目的であり、正規表現はおまけだと考えるとよいかもしれません。 正規表現を扱えるユーザならreplaceAllなんかあってもなくてもどうにもでもできるでしょうしね。 Logical Assignment Operators いまいち適切な日本語が見当たらないのですが、論理演算子+代入演算子です。 // 論理和代入 a ||= b; a || (a = b); // ↑と同じ // 論理積代入 a &&= b; a && (a = b); // ↑と同じ // null合体代入 a ??= b; a ?? (a = b); // ↑と同じ a ||= bは、aがtrueっぽい値であればそのままで、falseっぽい値であればbにする、ということになります。 論理演算子||と代入演算子=の合体ということで論理代入演算子とでも呼べばいいですかね。 Numeric separators 数値セパレータです。 100000000 === 100_000_000; // true 数値を_で区切って表記できるようになります。 あくまで表記上人間が見やすいようにするだけのもので、実体としては_が無い状態と同一です。 10進数以外の数値にも使えます。 また__と2個以上連続する、小数点や進数記号の前後につける、などの表記は禁止されます。 何故かRFCではハブられているのですが、PHPの数値セパレータとほぼ同じ文法です。 42; // OK 4_2; // OK 4__2: // SyntaxError: Only one underscore is allowed as numeric separator 42_; // SyntaxError: Numeric separators are not allowed at the end of numeric literals _42; // これはただの変数 0.12; // OK 0.1_2; // OK 0._12; // SyntaxError: Invalid or unexpected token 0xABCD; // OK 0xAB_CD; // OK 0x_ABCD; // SyntaxError: Invalid or unexpected token WeakRefs 弱い参照です。 PHPに実装されたときにも思ったのですが、毎回インスタンス使い捨ての言語で弱参照を使う意味がよくわからないんですよね。 Qiitaにちょうど詳しい解説があったので、詳細はそちらを見てください。 が、やっぱり必要性はよくわからん。 Nodeなら使い道があるのかな? RFCの例 class FileStream { static #cleanUp(heldValue) { console.error(`File leaked: ${file}!`); } static #finalizationGroup = new FinalizationRegistry(FileStream.#cleanUp); #file; constructor(fileName) { this.#file = new File(fileName); FileStream.#finalizationGroup.register(this, this.#file, this); // 略 ファイルを読み込む } close() { FileStream.#finalizationGroup.unregister(this); File.close(this.#file); } async *[Symbol.iterator]() { // 略 this.#fileからデータを読む } } const fs = new FileStream('path/to/some/file'); for await (const data of fs) { // 略 なんかする } fs.close(); これはRFCに載っていた例です。 ・new FinalizationRegistryで、GCされたときに呼ばれるコールバック関数を登録する。 ・コンストラクタのregisterで、GC対象のオブジェクトを登録する。 ・ファイナライザのunregisterで、登録したオブジェクトを解除する。 closeする前にGCでオブジェクトが消去されると、FileStream.#cleanUpメソッドが呼ばれてコンソールにログが吐かれます。 ドキュメントには『重要なロジックには使うな』とか書かれてあってどうすんだこれ。 感想 JavaScriptのドラスティックな変化ってのはだいたいES2017-2018あたりでやり尽くされたかんじがあり、それ以降は"ちょっとした便利な機能の追加"ってのが多い気がしますね。 ES2021も、文法が大きく変わるような変更は入っておらず、今後書くのがちょっと楽になるようなものがメインです。 もちろんそれぞれの追加機能も便利ではあるのですが、せっかくだから何かasync/awaitレベルの革新的な機能もほしいところですね。
- 投稿日:2021-08-09T15:15:57+09:00
nodeで.envを作る
node.jsで設定ファイルを一か所に集めたくなったので、 envファイルで設定値を保存したくなったので調べたら dotenvっていうパッケージを見つけたので、 使ってみた。 dotenvのページ インストール npm install dotenv .envファイルを作る プロジェクトルートに.envファイルを作る 中身はこんな感じ /.env hoge = hogehogehogehoesetting # 環境変数を定義コメントはこんな感じ fuga = 設定値 使う index.jsに以下を配置 require('dotenv').config(); 他のファイルでは、読み込む必要は無い 設定値の読みだし console.log(process.env.hoge) // hogehogehogehoesetting って感じで、設定している文字列を取得できる ちょう簡単
- 投稿日:2021-08-09T15:06:22+09:00
nodeで対話型のCLIを作る。
コマンドライン受付部分を作る index.js const readline = require('readline'); require('dotenv').config(); const Handler = require('./cli/handler'); const start = async ()=>{ await Handler.init();//起動と同時にやる処理 //準備 const reader = readline.createInterface({ input: process.stdin, // 標準入力 output: process.stdout, // 標準出力 prompt :'やること入力して> ' }); // Enterキー押下で読み込み reader.on('line', async (line) => { if(line !== ''){ //入力を取得して、コマンドと引数に分ける const args = line.split(/\s+/); const cmd = args.shift(); //Handlerの中の関数を実行する await Handler[cmd](args); }else{ console.log(''); } reader.prompt(); }); // ctrl+Cで終了 reader.on('close', () => { console.log("finish"); }); // コマンドプロンプトを表示 reader.prompt(); } //実行 start(); 実行するコマンドを定義する handler.js const Hogehoge = require('../app/hogehoge'); const handler = { 'init': async (args)=>{ console.log('システムにログインする'); await Hogehoge.fugafuga(); console.log('ログイン完了'); }, comandFuga:async()=>{ console.log('comandFugaを実行'); await Hogehoge.fuga(); console.log('完了') }, 's': async(args)=>{ console.log('オレオレコマンドなら、1文字のコマンドの方がいいよね'); await Hogehoge.search(args); console.log('表示完了'); }, }; module.exports = handler;
- 投稿日:2021-08-09T15:00:33+09:00
AI機械学習で、マスク着用判定(マスクずらし)
【新型コロナウィルス感染防止(不織布マスクの徹底)】 新型コロナウィルス感染拡大に伴い、マスク着用が日常化しましたね。 私の会社では、不織布マスクの着用が義務付けられており、防疫プロトコルを徹底しております。 しかし、気温上昇に伴い、ある問題が発生しております。 【ある問題、、、、、】 気温上昇に伴い、マスク外しやマスクをずらした会話が散見されるようになりました。 この身近な課題に対して、AI機械学習×LINE Botを活用し、解決しようと考えました。 【今回制作した仕組み】 マスク着用の判定を行い、着用していない場合は、LINE Botへ注意喚起のメール配信 【仕組みの動画】 【開発環境の準備】 ・Teachable Machine ・CodePen ・Node-Red ・Heroku ・LINE Bot 【システム構成】 Teachable Machine 画像 【Teachable machineで、下記写真の取り込み】 ■不織布マスク着用時 ■マスク外し ■マスクずらし 【CodePenの構成】 CodePenで実際に作成したURLを添付致します。 実際に試してみてください! 【お試しURL】 URLを貼る 【CodePenの構成】 ■HTML <button onclick="start()">開始</button><br> <video id="webcam"width="320" height="240" muted autoplay playsinline></video> <p id="result"></p> ■CSS #webcam{transform: scaleX(-1); } ■JS ``` //作成したモデルのURL const imageModelURL = 'https://teachablemachine.withgoogle.com/models/hOapgI7kG/'; // メインの関数(ここでは定義しているだけでボタンクリックされたら実行) async function start() { // カメラからの映像ストリーム取得 const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true, }); // 「id="webcam"」となっているパーツ(videoタグ)を取得 const video = document.getElementById('webcam'); // videoにカメラ映像ストリームをセット video.srcObject = stream; // Googleのサーバーにアップロードした自作モデルを読み込みにいきます classifier = ml5.imageClassifier(imageModelURL + 'model.json', video, () => { // 読み込みが完了次第ここが実行されます console.log('モデルの読み込みが完了しました'); }); // 繰り返し処理 function loop() { // 推論を実行し、エラーがあればerrに、結果をresultsに格納して、 // 推論が完了次第 { } の中身を実行します classifier.classify(async (err, results) => { // 結果のresultsは配列ですが、先頭に中身があれば以下の処理を実行します if (results[0]) { // 自分の顔の画像を取得 const myFace = results[0].label; // 判定結果を入れる変数を用意 let judge = ''; const result = document.getElementById("result"); // 判定 if (myFace == 'マスクあり') { // マスクあり result.style.color = 'green'; judge = 'OKです!'; } else { // マスクなし result.style.color = 'red'; judge = 'マスクをつけて下さい!'; } // HTML表示に反映 result.textContent = judge; axios.post('https://nodered-egashira.herokuapp.com/receiver', {judge: judge}); } // 推論終了1秒後に自分の関数を実行(ループになる) setTimeout(loop, 1000); }); } // 最初の繰り返し処理を実行 loop(); }``` 【Node-RED構成】 【各ノード詳細】 「http inノード」:ラインサーバーから送信されたメッセージ取得 メソッド:POST URL:/receiver 「functionノード」:ノード(左側):受け取ったメッセージ格納 「debugノード」:データの中身を確認 「PushMessageノード」:サーバーから、通知をLINEへ配信 まとめ 今回AI機械学習&Node-RED&LINE Botを連携させ、 マスク着用の判断を実際に作成してみました。 今後は、布マスク判定やアルコール消毒状況なども反映させる仕組みなどを構築していきたいと思います。
- 投稿日:2021-08-09T15:00:33+09:00
AI機械学習で、マスク着用判定(マスク着用・マスクなし・マスクずらし)
【新型コロナウィルス感染防止】 新型コロナウィルス感染拡大に伴い、マスク着用が日常化しましたね。 私の会社では、不織布マスクの着用が義務付けられており、防疫プロトコルを徹底しております。 しかし、気温上昇に伴い、ある問題が発生、、、(;^ω^) 【ある問題とは、、、、、】 不織布マスクの徹底は、されてきたのですが、気温上昇に伴い、マスク外しやマスクをずらした会話が散見されるようになりました。この身近な課題に対して、AI機械学習×LINE Botを活用し、解決しようと考えました。 【今回制作した仕組み】 マスク着用の判定を行い、着用していない場合やマスクをずらした場合は、LINE Botへ注意喚起のメール配信 【仕組みの動画】 CodePen画像判定 LINE Botメール配信 【開発環境の準備】 ・Teachable Machine ・CodePen ・Node-Red ・Heroku ・LINE Bot 【システム構成】 Teachable Machine 【Teachable machineで、写真の取り込み】 ■不織布マスク着用時 ■マスク外し ■マスクずらし 【CodePenの構成】 CodePenで実際に作成したURLを添付致します。 実際に試してみてください! 【お試しURL】 URLを貼る 【CodePenの構成】 ■HTML ■CSS #webcam{transform: scaleX(-1);} ■JS ```//作成したモデルのURL const imageModelURL = 'https://teachablemachine.withgoogle.com/models/hOapgI7kG/'; // メインの関数(ここでは定義しているだけでボタンクリックされたら実行) async function start() { // カメラからの映像ストリーム取得 const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true, }); // 「id="webcam"」となっているパーツ(videoタグ)を取得 const video = document.getElementById('webcam'); // videoにカメラ映像ストリームをセット video.srcObject = stream; // Googleのサーバーにアップロードした自作モデルを読み込みにいきます classifier = ml5.imageClassifier(imageModelURL + 'model.json', video, () => { // 読み込みが完了次第ここが実行されます console.log('モデルの読み込みが完了しました'); }); // 繰り返し処理 function loop() { // 推論を実行し、エラーがあればerrに、結果をresultsに格納して、 // 推論が完了次第 { } の中身を実行します classifier.classify(async (err, results) => { // 結果のresultsは配列ですが、先頭に中身があれば以下の処理を実行します if (results[0]) { // 自分の顔の画像を取得 const myFace = results[0].label; // 判定結果を入れる変数を用意 let judge = ''; const result = document.getElementById("result"); // 判定 if (myFace == 'マスクあり') { // マスクあり result.style.color = 'green'; judge = 'OKです!'; } else { // マスクなし result.style.color = 'red'; judge = 'マスクをつけて下さい!'; } // HTML表示に反映 result.textContent = judge; axios.post('https://nodered-egashira.herokuapp.com/receiver', {judge: judge}); } // 推論終了1秒後に自分の関数を実行(ループになる) setTimeout(loop, 1000); }); } // 最初の繰り返し処理を実行 loop(); }``` 【Node-RED構成】 【各ノード詳細】 「http inノード」:ラインサーバーから送信されたメッセージ取得 メソッド:POST URL:/receiver 「functionノード」:ノード(左側):受け取ったメッセージ格納 「debugノード」:データの中身を確認 「PushMessageノード」:サーバーから、通知をLINEへ配信 まとめ 今回AI機械学習&Node-RED&LINE Botを連携させ、 マスク着用の判断を実際に作成してみました。 今後は、布マスク判定やアルコール消毒状況なども反映させる仕組みなどを構築していきたいと思います。
- 投稿日:2021-08-09T15:00:33+09:00
AI機械学習で、マスク着用判定 (マスク着用・マスクなし・マスクずらし)
【新型コロナウィルス感染防止】 新型コロナウィルス感染拡大に伴い、マスク着用が日常化しましたね。 私の会社では、不織布マスクの着用が義務付けられており、防疫プロトコルを徹底しております。 しかし、気温上昇に伴い、ある問題が発生、、、(;^ω^) 【ある問題とは、、、、、】 不織布マスクの徹底は、されてきたのですが、気温上昇に伴い、マスク外しやマスクをずらした会話が散見されるようになりました。この身近な課題に対して、AI機械学習×LINE Botを活用し、解決しようと考えました。 【今回制作した仕組み】 マスク着用の判定を行い、着用していない場合やマスクをずらした場合は、LINE Botへ注意喚起のメール配信 【仕組みの動画】 CodePen画像判定 LINE Botメール配信 【開発環境の準備】 ・Teachable Machine ・CodePen ・Node-Red ・Heroku ・LINE Bot 【システム構成】 Teachable Machine 【Teachable machineで、写真の取り込み】 ■不織布マスク着用時 ■マスク外し ■マスクずらし 【CodePenの構成】 CodePenで実際に作成したURLを添付致します。 実際に試してみてください! 【お試しURL】 URLを貼る 【CodePenの構成】 ■HTML ■CSS #webcam{transform: scaleX(-1);} ■JS ```//作成したモデルのURL const imageModelURL = 'https://teachablemachine.withgoogle.com/models/hOapgI7kG/'; // メインの関数(ここでは定義しているだけでボタンクリックされたら実行) async function start() { // カメラからの映像ストリーム取得 const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true, }); // 「id="webcam"」となっているパーツ(videoタグ)を取得 const video = document.getElementById('webcam'); // videoにカメラ映像ストリームをセット video.srcObject = stream; // Googleのサーバーにアップロードした自作モデルを読み込みにいきます classifier = ml5.imageClassifier(imageModelURL + 'model.json', video, () => { // 読み込みが完了次第ここが実行されます console.log('モデルの読み込みが完了しました'); }); // 繰り返し処理 function loop() { // 推論を実行し、エラーがあればerrに、結果をresultsに格納して、 // 推論が完了次第 { } の中身を実行します classifier.classify(async (err, results) => { // 結果のresultsは配列ですが、先頭に中身があれば以下の処理を実行します if (results[0]) { // 自分の顔の画像を取得 const myFace = results[0].label; // 判定結果を入れる変数を用意 let judge = ''; const result = document.getElementById("result"); // 判定 if (myFace == 'マスクあり') { // マスクあり result.style.color = 'green'; judge = 'OKです!'; } else { // マスクなし result.style.color = 'red'; judge = 'マスクをつけて下さい!'; } // HTML表示に反映 result.textContent = judge; axios.post('https://nodered-egashira.herokuapp.com/receiver', {judge: judge}); } // 推論終了1秒後に自分の関数を実行(ループになる) setTimeout(loop, 1000); }); } // 最初の繰り返し処理を実行 loop(); }``` 【Node-RED構成】 【各ノード詳細】 「http inノード」:ラインサーバーから送信されたメッセージ取得 メソッド:POST URL:/receiver 「functionノード」:ノード(左側):受け取ったメッセージ格納 「debugノード」:データの中身を確認 「PushMessageノード」:サーバーから、通知をLINEへ配信 まとめ 今回AI機械学習&Node-RED&LINE Botを連携させ、 マスク着用の判断を実際に制作してみました。 多くの種類をTeachable Machineに登録させ、判定する事が出来なかった点が課題です。 今後は、アルコール消毒状況なども反映させる仕組みなどを構築していきたいと思います。 出社時に消毒状況を検知する仕組みなども制作していきたいと思います。 【今後作成していきたい課題をピックアップ】 俺がやる!成長させる!ニューリテール企業へ!日々の業務課題5つ深掘りしてみました!#protoout #業務課題@EgashiraYosuke #note #私らしいはたらき方 https://t.co/Aweu9dB169— YE0905 (@EgashiraYosuke) August 9, 2021
- 投稿日:2021-08-09T15:00:33+09:00
AI機械学習で、マスクチェッカ― (マスク着用・マスクなし・マスクずらしを判定し、LINE Botへ注意喚起)
【新型コロナウィルス感染防止】 新型コロナウィルス感染拡大に伴い、マスク着用が日常化しましたね。 私の会社では、不織布マスクの着用が義務付けられており、防疫プロトコルを徹底しております。 しかし、気温上昇に伴い、ある問題が発生、、、(;^ω^) 【ある問題とは、、、、、】 不織布マスクの徹底は、されてきたのですが、気温上昇に伴い、マスク外しやマスクをずらした会話が散見されるようになりました。この身近な課題に対して、AI機械学習×LINE Botを活用し、解決しようと考えました。 【今回制作した仕組み】 マスクチェッカー (マスク着用の判定を行い、着用していない場合やマスクをずらした場合は、LINE Botへ注意喚起のメール配信 【仕組みの動画】 CodePen LINE Botメール配信 【開発環境の準備】 ・Teachable Machine ・CodePen ・Node-Red ・Heroku ・LINE Bot 【システム構成】 Teachable Machineで画像を取り込み ■不織布マスク着用時 ■マスク外し ■マスクずらし ■布マスク 【CodePenの構成】 CodePenで実際に作成したURLを添付致します。 実際に試してみてください! 【お試しURL】 URLを貼る 【CodePenの構成】 ■HTML ■CSS #webcam{transform: scaleX(-1);} ■JS ```//作成したモデルのURL const imageModelURL = 'https://teachablemachine.withgoogle.com/models/hOapgI7kG/'; // メインの関数(ここでは定義しているだけでボタンクリックされたら実行) async function start() { // カメラからの映像ストリーム取得 const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true, }); // 「id="webcam"」となっているパーツ(videoタグ)を取得 const video = document.getElementById('webcam'); // videoにカメラ映像ストリームをセット video.srcObject = stream; // Googleのサーバーにアップロードした自作モデルを読み込みにいきます classifier = ml5.imageClassifier(imageModelURL + 'model.json', video, () => { // 読み込みが完了次第ここが実行されます console.log('モデルの読み込みが完了しました'); }); // 繰り返し処理 function loop() { // 推論を実行し、エラーがあればerrに、結果をresultsに格納して、 // 推論が完了次第 { } の中身を実行します classifier.classify(async (err, results) => { // 結果のresultsは配列ですが、先頭に中身があれば以下の処理を実行します if (results[0]) { // 自分の顔の画像を取得 const myFace = results[0].label; // 判定結果を入れる変数を用意 let judge = ''; const result = document.getElementById("result"); // 判定 if (myFace == 'マスクあり') { // マスクあり result.style.color = 'green'; judge = 'OKです!'; } else { // マスクなし result.style.color = 'red'; judge = 'マスクをつけて下さい!'; } // HTML表示に反映 result.textContent = judge; axios.post('https://nodered-egashira.herokuapp.com/receiver', {judge: judge}); } // 推論終了1秒後に自分の関数を実行(ループになる) setTimeout(loop, 1000); }); } // 最初の繰り返し処理を実行 loop(); }``` 【Node-RED構成】 【各ノード詳細】 「http inノード」:ラインサーバーから送信されたメッセージ取得 メソッド:POST URL:/receiver 「functionノード」:ノード(左側):受け取ったメッセージ格納 //http requestの結果を取得する const result = msg.payload.judge; // 返信したいメッセージの変数を定義しておく let reply = JSON.stringify(result); // 返信メッセージをreplyの中身にする msg.payload = reply; return msg; 「debugノード」:データの中身を確認 「PushMessageノード」:サーバーから、通知をLINEへ配信 まとめ 今回AI機械学習&Node-RED&LINE Botを連携させ、 マスク着用の判断を実際に制作してみました。 多くの種類をTeachable Machineに登録させ、判定する事が出来なかった点が課題です。 今後は、アルコール消毒状況なども反映させる仕組みなどを構築していきたいと思います。 出社時に消毒状況を検知する仕組みなども制作していきたいと思います。 【今後作成していきたい課題をピックアップ】 俺がやる!成長させる!ニューリテール企業へ!日々の業務課題5つ深掘りしてみました!#protoout #業務課題@EgashiraYosuke #note #私らしいはたらき方 https://t.co/Aweu9dB169— YE0905 (@EgashiraYosuke) August 9, 2021
- 投稿日:2021-08-09T15:00:33+09:00
AI機械学習で、マスクチェッカ― (不織布マスク着用・布マスク・マスクなし・マスクずらしを判定し、LINE Botへ注意喚起)
【新型コロナウィルス感染防止対策】 新型コロナウィルス感染拡大に伴い、マスク着用が日常化しましたね。 私の会社では、不織布マスクの着用が義務付けられており、防疫プロトコルを徹底しております。 しかし、気温上昇に伴い、ある問題が発生、、、(;^ω^) 【ある問題とは、、、、、】 不織布マスク着用は、徹底されてきたのですが、気温上昇に伴い、マスク外しやマスクをずらした会話が散見されるようになりました。この身近な課題に対して、AI機械学習×LINE Botを活用し、解決しようと考えました。 【今回制作した仕組み】 マスクチェッカー 不織布マスク着用=OK それ以外=注意喚起メール配信 【仕組みの画像&動画】 LINE Botメール配信 【開発環境の準備】 ・Teachable Machine ・CodePen ・Node-Red ・Heroku ・LINE Bot 【システム構成】 Teachable Machineで画像を取り込み ■不織布マスク着用時 ■マスク外し ■マスクずらし ■布マスク 【お試しURL】 【CodePenの構成】 ■HTML <button onclick="start()">開始</button><br> <video id="webcam" width="320" height="240" muted autoplay playsinline></video> <p id="result"></p> ■CSS #webcam{transform: scaleX(-1);} ■JS //作成したモデルのURL const imageModelURL = 'teachablemachineURL'; // メインの関数(ここでは定義しているだけでボタンクリックされたら実行) async function start() { // カメラからの映像ストリーム取得 const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true, }); // 「id="webcam"」となっているパーツ(videoタグ)を取得 const video = document.getElementById('webcam'); // videoにカメラ映像ストリームをセット video.srcObject = stream; // Googleのサーバーにアップロードした自作モデルを読み込みにいきます classifier = ml5.imageClassifier(imageModelURL + 'model.json', video, () => { // 読み込みが完了次第ここが実行されます console.log('モデルの読み込みが完了しました'); }); // 繰り返し処理 function loop() { // 推論を実行し、エラーがあればerrに、結果をresultsに格納して、 // 推論が完了次第 { } の中身を実行します classifier.classify(async (err, results) => { // 結果のresultsは配列ですが、先頭に中身があれば以下の処理を実行します if (results[0]) { // 自分の顔の画像を取得 const myFace = results[0].label; // 判定結果を入れる変数を用意 let judge = ''; const result = document.getElementById("result"); // 判定 if (myFace == 'マスクあり') { // マスクあり result.style.color = 'green'; judge = 'OKです!'; } else { // マスクなし result.style.color = 'red'; judge = 'マスクをつけて下さい!'; } // HTML表示に反映 result.textContent = judge; axios.post('https://nodered-egashira.herokuapp.com/receiver', {judge: judge}); } // 推論終了1秒後に自分の関数を実行(ループになる) setTimeout(loop, 5000); }); } // 最初の繰り返し処理を実行 loop(); } 【Node-RED構成】 【各ノード詳細】 「http inノード」:ラインサーバーから送信されたメッセージ取得 メソッド:POST URL:/receiver 「functionノード」:ノード(左側):受け取ったメッセージ格納 const message = msg.payload.judge; msg.url="https://api.line.me/v2/bot/message/push"; msg.method="POST"; var ACCESS_TOKEN = "アクセストークン記載"; msg.headers={ "Authorization":`Bearer ${ACCESS_TOKEN}`, "Content-Type":"application/json" }; msg.payload={ "to":"ユーザーID記載", //誰に送るか指定 "messages":[ //メッセージ内容を指定 { "type":"text", "text": message}]}; return msg; 「debugノード」:データの中身を確認 「PushMessageノード」:サーバーから、通知をLINEへ配信 【まとめ】 今回AI機械学習&Node-RED&LINE Botを連携させ、 マスク着用の判断を実際に制作してみました。 多くの種類をTeachable Machineに登録させ、判定する事が出来なかった点が課題です。 今後は、アルコール消毒状況なども反映させる仕組みなどを構築していきたいと思います。 出社時に消毒状況を検知する仕組みなども制作していきたいと思います。 【今後作成していきたい5つの業務課題】 俺がやる!成長させる!ニューリテール企業へ!日々の業務課題5つ深掘りしてみました!#protoout #業務課題@EgashiraYosuke #note #私らしいはたらき方 https://t.co/Aweu9dB169— YE0905 (@EgashiraYosuke) August 9, 2021
- 投稿日:2021-08-09T15:00:33+09:00
AI機械学習で、マスクチェッカ―(不織布マスク着用・布マスク・マスクなし・マスクずらしを判定し、LINE Botへ注意喚起)
【新型コロナウィルス感染防止対策】 新型コロナウィルス感染拡大に伴い、マスク着用が日常化されましたね。 私の会社では、不織布マスクの着用が義務付けられており、防疫プロトコルを徹底しております。 しかし、気温上昇に伴い、ある問題が発生、、、(;^ω^) 【ある問題とは、、、、、】 不織布マスク着用は、徹底されてきたのですが、気温上昇に伴い、マスク外しやマスクをずらした会話が散見されるようになりました。この身近な課題に対して、AI機械学習×LINE Botを活用し、解決しようと考えました。 【今回制作した仕組み】 マスクチェッカー 不織布マスク着用の場合=OKです! 布マスク、マスクなし、マスクずらし=注意喚起メール配信(マスクをつけて下さい!) 【仕組みの動画】 ■CodePen マスクチェッカ―!連携させるのが難しい!汗何とかできたが、まだまだ、、(;^ω^)#protoout #Java #nodered #lINEBot pic.twitter.com/CTLz8P2BJF— YE0905 (@EgashiraYosuke) August 9, 2021 ■LINE Botメール配信 LINE Botと連携#protoout pic.twitter.com/h2hDdoTrjp— YE0905 (@EgashiraYosuke) August 9, 2021 【開発環境の準備】 ・Teachable Machine ・CodePen ・Node-Red ・Heroku ・LINE Bot 【システム構成】 Teachable Machineで画像を取り込み ■不織布マスク着用時 ■マスク外し ■マスクずらし ■布マスク 【お試しURL】 【CodePenの構成】 image.png ■HTML <div class="wrapper"> <div class="title"> <h1>マスクチェッカー</h1> </div> <div class="camera"> <button onclick="start()" class="button start">開始</button> <br> <video id="webcam" width="320px" height="240px" muted autoplay playsinline></video> </div> <div class="result"><p id="result"></p></div> </div> ■CSS #webcam { transform: scaleX(-1);} .wrapper { display: flex; justify-content:center; align-items: center; flex-direction: column; width: 100vw; heigth: 100vh;} .camera { text-align: center; width: 100vw; heigth: 100vh;} .button { width: 100px; height: 50px; margin-bottom: 30px;} .start { margin-rigth: 30px;} ■JS //作成したモデルのURL const imageModelURL = 'teachablemachineURL'; // メインの関数(ここでは定義しているだけでボタンクリックされたら実行) async function start() { // カメラからの映像ストリーム取得 const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true, }); // 「id="webcam"」となっているパーツ(videoタグ)を取得 const video = document.getElementById('webcam'); // videoにカメラ映像ストリームをセット video.srcObject = stream; // Googleのサーバーにアップロードした自作モデルを読み込みにいきます classifier = ml5.imageClassifier(imageModelURL + 'model.json', video, () => { // 読み込みが完了次第ここが実行されます console.log('モデルの読み込みが完了しました'); }); // 繰り返し処理 function loop() { // 推論を実行し、エラーがあればerrに、結果をresultsに格納して、 // 推論が完了次第 { } の中身を実行します classifier.classify(async (err, results) => { // 結果のresultsは配列ですが、先頭に中身があれば以下の処理を実行します if (results[0]) { // 自分の顔の画像を取得 const myFace = results[0].label; // 判定結果を入れる変数を用意 let judge = ''; const result = document.getElementById("result"); // 判定 if (myFace == 'マスクあり') { // マスクあり result.style.color = 'green'; judge = 'OKです!'; } else { // マスクなし result.style.color = 'red'; judge = 'マスクをつけて下さい!'; } // HTML表示に反映 result.textContent = judge; axios.post('https://nodered-egashira.herokuapp.com/receiver', {judge: judge}); } // 推論終了0.5秒後に自分の関数を実行(ループになる) setTimeout(loop, 500); }); } // 最初の繰り返し処理を実行 loop(); } 【Node-RED構成】 【各ノード詳細】 「http inノード」:ラインサーバーから送信されたメッセージ取得 メソッド:POST URL:/receiver 「functionノード」:ノード(左側):受け取ったメッセージ格納 const message = msg.payload.judge; msg.url="https://api.line.me/v2/bot/message/push"; msg.method="POST"; var ACCESS_TOKEN = "アクセストークン記載"; msg.headers={ "Authorization":`Bearer ${ACCESS_TOKEN}`, "Content-Type":"application/json" }; msg.payload={ "to":"ユーザーID記載", //誰に送るか指定 "messages":[ //メッセージ内容を指定 { "type":"text", "text": message}]}; return msg; 「http requestノード」:受けっとったメッセージをLINEに通知を配信 「debugノード」:サーバー稼働を確認 【まとめ】 今回AI機械学習&Node-RED&LINE Botを連携させ、 マスク着用の判断を実際に制作してみました。 マスク多種類をTeachable Machineに登録させ、判定する事が出来なかった点が課題です。 今後は、アルコール消毒状況なども反映させる仕組みや売場管理を把握できるシステムなどを制作していきたいと思っております。 【今後作成していきたい5つの業務課題】 俺がやる!成長させる!ニューリテール企業へ!日々の業務課題5つ深掘りしてみました!#protoout #業務課題@EgashiraYosuke #note #私らしいはたらき方 https://t.co/Aweu9dB169— YE0905 (@EgashiraYosuke) August 9, 2021
- 投稿日:2021-08-09T14:19:21+09:00
?Docusaurusの複雑なプラグイン設定にハマるな
この記事ではVercelでデプロイしたDocusaurus 2.0.0-beta.4を使っています。 説明 Docusaurusを使い始めたが、あまりにもDocusaurusを使っている途中にハマってしまったので、紹介したいと思う。 Docusaurusには、プラグインという機能がある。 例えば - docsとblog以外のディレクトリを追加する(@docusaurus/plugin-content-docs) - Google Analyticsと連携する などの機能がある。 私はこの両方の機能の設定でハマった。 plugin-content-docs で複数ディレクトリを追加する 公式サイトの説明はこちら。 例えば、 - docs - blog - article - memo - qanda というディレクトリ構成にする。 この場合、docsとblogはデフォルトで使用可能だが、articleとmemoとqandaは存在しない。 このプラグインを設定する時、値は一つしか設定できない。 だが複数設定する際には、以下のように@docusaurus/plugin-content-docsプラグイン自体を複数参照すればいい。 plugins: [ [ '@docusaurus/plugin-content-docs', { id: 'article', path: 'article', routeBasePath: 'article', editUrl: "https://github.com/OO/OOO/edit/main", editCurrentVersion: true, sidebarPath: require.resolve('./article/sidebars.js'), showLastUpdateAuthor: true, showLastUpdateTime: true, }, ], [ '@docusaurus/plugin-content-docs', { id: 'memo', path: 'memo', routeBasePath: 'memo', editUrl: "https://github.com/OO/OOO/edit/main", editCurrentVersion: true, sidebarPath: require.resolve('./memo/sidebars.js'), showLastUpdateAuthor: true, showLastUpdateTime: true, }, ], [ '@docusaurus/plugin-content-docs', { id: 'qanda', path: 'qanda', routeBasePath: 'qanda', editUrl: "https://github.com/OO/OOO/edit/main", editCurrentVersion: true, sidebarPath: require.resolve('./qanda/sidebars.js'), showLastUpdateAuthor: true, showLastUpdateTime: true, }, ], plugin-google-analyticsの場所 公式サイトはこちら。 公式サイトによると module.exports = { plugins: ['@docusaurus/plugin-google-analytics'], themeConfig: { googleAnalytics: { trackingID: 'UA-141789564-1', // Optional fields. anonymizeIP: true, // Should IPs be anonymized? }, }, }; とある。 実はこのプラグインは特殊でJavaScriptのplugin下には書かず、module.export下に書く。 なので、このようになる。 module.exports = { plugins: ['@docusaurus/plugin-google-analytics'], themeConfig: { googleAnalytics: { trackingID: 'UA-141789564-1', // Optional fields. anonymizeIP: true, // Should IPs be anonymized? }, }, plugins: [ ] }; 初めての記事投稿なので下手ですが、是非コメントで改善点を教えてくださると助かります! Docusaurusユーザーがハマらずに設定出来たらうれしいです。 参考 Sasigume氏によるTweetのリプライ NikitaIT氏によるissue Discordによるサポート
- 投稿日:2021-08-09T14:16:25+09:00
Chrome 拡張機能を作って公開しよう 最終回 〜Web ストアに公開〜
この記事は、先日ブログに投稿したものと同じ内容です。ぜひ、ブログの方もご覧ください。 拡張機能を作るシリーズ、最終回!いよいよ、拡張機能を Web ストアに公開していきます! 開発者登録 まずは、Web ストアにアクセスします。 アクセスしたら、拡張機能を公開する Google アカウントにログインし、「デベロッパーダッシュボード」に移動してください。 本人確認を求められたら、指示に従って本人確認を行ってください。 余談ですが、Google アカウントはサイバー攻撃の対象にされやすいので、複雑なパスワードに加えて2段階認証をかけておくことを強くおすすめします。 このような画面になったら、デベロッパー契約とプライバシー ポリシーを確認し、同意する場合はチェックボックスにチェックを入れてください。 その後、画面右側の「登録料の支払い」をクリックしてください。 Web ストアでは、悪意のある開発者の登録を防ぐため、5ドルの登録料が必要になります。この支払いは、登録時の1回のみで、拡張機能をアップデートする際や新たな拡張機能をリリースする際に支払いを求められることはありません。 プロフィール登録 左側のタブから、「アカウント」をクリックしてください。 そうしたら、「投稿者の表示名」と「メールアドレス」を入力してください。これらは公開されるので、公開しても良い名前(偽名でも OK)・メールアドレス(メールアドレスは正確に)にしてください。 プライバシーポリシーがある場合は、3番目のテキストボックスに、その URL を入力してください。(これは任意です。) 下の方にスクロールしていくと、「通知」という設定項目があります。これをすべてオンにしておくと、拡張機能が公開された時や拡張機能にレビューが付いた時などに通知を受け取れるので便利です。 設定を終えたら、一番下にある 「変更を保存」をクリック してください。 拡張機能のアップロード 「投稿者の設定が正常に更新されました」と表示されたら、左側のタブから「アイテム」をクリックしてください。 ここでは、拡張機能の公開・公開した拡張機能の管理などを行うことができます。 まずは、「新しいアイテム」ボタンをクリックしてください。 「ZIP ファイルをここにドロップするか、ファイルを選択します。」とあるので、拡張機能の開発用フォルダを .zip 形式に圧縮したものをこの枠の中にドラッグ&ドロップします。 .zip ファイルへの圧縮は、開発用フォルダの中にある8個のファイルを全選択して、右クリック→「送信」→「圧縮 (zip 形式) フォルダー」のように選んでいくと行えます。 Windows での手順 Mac での手順 Chromebook での手順 アップロードが完了すると、拡張機能に関する情報を入力する画面に遷移します。 ※「パッケージのタイトル」や「パッケージの概要」が途中までしか表示されていませんが、横にスクロールするとすべて見られます。分かりにくいですね。 必要事項の入力 画面右上に「下書きとして保存する」ボタンがあるので、こまめに保存しておくとトラブルを回避できます。 また、その左側に「送信できない理由」というボタンがあるので、そこをクリックすると、何が不足しているのかがわかります。 ※「送信できない理由」は、下書きとして保存したあとに、最新の状態が反映されます。 「プレビュー」をクリックすると、実際のWebストアの画面が開き、どのように拡張機能が公開されるのかがイメージしやすくなります。 それでは、拡張機能を公開する上での必要事項を入力していきます。 どのような感じで書くのかを説明しますが、具体的な内容はご自身でお考えください。 説明 拡張機能に関する説明。できるだけ詳しく書いたほうが良いです。GitHub などでコードを公開している場合は、その URL も入れるとさらに良いです。なお、「パッケージの概要」の内容は公開時に自動で説明欄に追加されるので、同じ内容を説明欄に書く必要はありません。 カテゴリ この拡張機能がどのカテゴリに属すかを選んでください。Web ストア上の似たような拡張機能を見てみると、「仕事効率化」や「ユーザー補助機能」を選んでいるものが多かったです。 言語 拡張機能で使われている言語。基本的に日本語(?) ショップアイコン Web ストアで表示されるアイコン。128ピクセル×128ピクセル。 全言語向けプロモーション動画 プロモーション動画を YouTube にアップロードしている場合は、その URL を入力してください。 任意 スクリーンショット この拡張機能がどのようなものかがわかる画像。最低1枚、最大5枚まで指定可能。画像サイズに指定があるので、よくご確認ください。 ※プロモーション画像のようにするにも、可能な限りスクリーンショットは入れたほうがよいです。 プロモーションタイトル(小)・(大)、マーキープロモーションタイトル 検索結果のページなどに表示される画像。こちらも画像サイズに指定があるので、よくご確認ください。 任意 追加フィールド ホームページの URL など、必要に応じて入力してください。 任意 ここまで書けたら、下書きとして保存した後、左側のタブから「プライバシーへの取り組み」をクリックしてください。 単一用途の目的 「要するにこの拡張機能の目的はなにか」を書きます。 権限が必要な理由 その権限をどのように利用するのかを書きます。 下に書いてあるものはあくまでも参考程度にし、より詳しく記述するようにしてください。 activeTab ユーザーが閲覧しているタブのID、タイトル、URLを取得するため scripting URLやページタイトル、あるいはその両方をクリップボードにコピーするスクリプトや、各種 SNS の投稿画面をポップアップで開くスクリプトを挿入するため contextMenus コンテキストメニューからもページを共有できるようにするため リモートコード 外部から、JavaScript、CSS、フォントを読み込んでいるか。 1つでも読み込んでいる場合は「はい」を選択し、なぜ外部から読み込む必要があるのかを記述してください。 データ使用 ユーザーのどのようなデータを開発者が得るのか、当てはまるものすべてにチェックを入れてください。 下の方に、3つのチェックボックスがあるので、「デベロッパー プログラム ポリシー」を遵守している場合は、チェックを入れてください。 ここまで入力できたら、下書きとして保存した後、左側のタブから「決済と配布地域」をクリックし、課金要素の有無・公開範囲・配布地域を設定してください。 ただ、このブログを見た方が全員「一般公開」にしてしまうと、類似品が複数出回ることになるので「限定公開」か「非公開」にしてください。 もし一般公開したい場合は、アイコンやデザイン、機能に、元がわからない程度のオリジナリティを加えるようお願いします。 「決済と配布地域」の設定も終えたら、もう一度下書きとして保存してください。 最後にもう一度見直して、不備がなければ「審査のため送信」をクリックしてください。 送信確認画面が現れます。 また、審査終了後に自動で公開するか否かを選択できます。お好きな方を選んでください。 再度「審査のため送信」をクリックすると、拡張機能が審査に提出されます。 これで、早くてその日のうちに、場合によっては数週間後に拡張機能の審査が終わります。 先程、審査合格後に手動で拡張機能を公開することを選んだ方は、審査合格のメールが来たら30日以内にデベロッパーダッシュボードから拡張機能を公開してください。 おわりに この連載も、ついに終わりを迎えます。 Chrome 拡張機能づくりの一連の流れがわかっていただけたのであれば、これ以上の喜びはありません。 これにて、連載企画「Chrome 拡張機能を作って公開しよう」を終わります。本当にお疲れさまでした! 【連載一覧】 環境構築 ポップアップ作成 仕様に沿って開発 コピー機能実装 権限を減らす コンテキストメニューの作成 Webストアに公開 (←今ココ)
- 投稿日:2021-08-09T14:15:59+09:00
Chrome 拡張機能を作って公開しよう⑥ 〜コンテキストメニューを作る〜
この記事は、先日ブログに投稿したものと同じ内容です。ぜひ、ブログの方もご覧ください。 1回15分で拡張機能を作るシリーズ、第6弾! 拡張機能も完成に近づいてきました!今回は、更に利便性を向上させるため、コンテキストメニュー(右クリックで開けるメニュー)からも拡張機能を起動できるようにします。 background.js の作成 開発用フォルダの一番上の階層に、background.js を作成してください。 これは、バックグラウンドで動く JavaScript ファイルで、ここからコンテキストメニューを作成することができます。 また、これを使用するにあたって、manifest.json の変更が必要になります。 manifest.json の書き換え manifest.json の permissions を以下のように書き換えてください。 manifest.json "permissions": ["activeTab","scripting","contextMenus"], scripting は、background.js が、表示しているページに対してスクリプトを実行するために必要な権限です。通常、background.js は DOM にアクセスできないのですが、この権限を追加することにより、多少不便ではありますが、DOM にアクセスすることが可能になります。 contextMenus は、コンテキストメニューを使用するために必要な権限です。 また、以下の内容を追記してください。background.js を使用することを明示します。 manifest.json "background": { "service_worker": "background.js" } 全体で以下のようになります。 manifest.json { "name": "タイトルとURLをコピー", "description": "ページのタイトルとURLを簡単にコピーできます。", "version": "1.0", "manifest_version": 3, "icons": { "16": "icon_16x16.png", "48": "icon_48x48.png", "128": "icon_128x128.png" }, "permissions": ["activeTab","scripting","contextMenus"], "action": { "default_title": "タイトルとURLをコピー", "default_icon": "icon_48x48.png", "default_popup": "popup.html" }, "background": { "service_worker": "background.js" } } コンテキストメニューの作成 background.js に以下の内容を記述してください。 background.js /* コンテキストメニューを作成 */ const parent = chrome.contextMenus.create({ id: "share", title: "ページを共有", contexts: ["all"], }); ここまでできたら、コードを保存して、chrome://extensions を開き、更新ボタンをクリックしてください。 ※以下、この操作を「拡張機能を更新する」と表現します。 下の画像のように、コンテキストメニューに「ページを共有」という項目が現れたでしょうか。 もしも現れない場合は、 chrome://extensions の更新ボタンを押したか 書いたプログラムを保存したか manifest.json の記述は正しいか を確認してみてください。 コードの解説 chrome.contextMenus.create でコンテキストメニューを作成することができます。また、この時にコンテキストメニューの設定をオブジェクト形式で行います。 項目 説明 id 後で参照する時に用いる ID title コンテキストメニューに表示する文字列 contexts どこを右クリックした際のコンテキストメニューに追加するか。詳細情報 子コンテキストメニューを作成する それでは、コンテキストメニューの一つ下の階層を作っていきましょう。 やり方は簡単。先程のコードに parentId プロパティを追加して、親コンテキストメニューを指定するだけです。 以下のコードを追加してください。 background.js chrome.contextMenus.create({ parentId: parent, id: "title", title: "ページタイトルをコピー", contexts: ["all"], }); chrome.contextMenus.create({ parentId: parent, id: "URL", title: "URL をコピー", contexts: ["all"], }); chrome.contextMenus.create({ parentId: parent, id: "both", title: "ページタイトルと URL をコピー", contexts: ["all"], }); chrome.contextMenus.create({ parentId: parent, id: "FB", title: "Facebook でシェア", contexts: ["all"], }); chrome.contextMenus.create({ parentId: parent, id: "tweet", title: "ツイート", contexts: ["all"], }); chrome.contextMenus.create({ parentId: parent, id: "LINE", title: "LINE で送る", contexts: ["all"], }); ここまでできたら、拡張機能を更新してください。 下の画像のようなコンテキストメニューになったでしょうか。 動作をつける background.js に以下のコードを追加してください。 background.js /* コンテキストメニューがクリックされた時の処理 */ chrome.contextMenus.onClicked.addListener((info, tab) => { switch (info.menuItemId) { case "title": chrome.scripting.executeScript({ target: { tabId: tab.id }, function: title, }); break; case "URL": chrome.scripting.executeScript({ target: { tabId: tab.id }, function: URL, }); break; case "both": chrome.scripting.executeScript({ target: { tabId: tab.id }, function: both, }); break; case "FB": chrome.scripting.executeScript({ target: { tabId: tab.id }, function: FB, }); break; case "tweet": chrome.scripting.executeScript({ target: { tabId: tab.id }, function: tweet, }); break; case "LINE": chrome.scripting.executeScript({ target: { tabId: tab.id }, function: LINE, }); break; } }); function title() { const element = document.createElement("textarea"); element.value = document.title; document.body.append(element); element.select(); document.execCommand("copy"); element.remove(); } function URL() { const element = document.createElement("textarea"); element.value = location.href; document.body.append(element); element.select(); document.execCommand("copy"); element.remove(); } function both() { const element = document.createElement("textarea"); element.value = document.title + "\n" + location.href; document.body.append(element); element.select(); document.execCommand("copy"); element.remove(); } function FB() { window.open( "https://www.facebook.com/share.php?u=" + encodeURIComponent(location.href), "tweetwindow", "width=650, height=470, personalbar=0, toolbar=0, scrollbars=1, sizable=1" ); } function tweet() { window.open( "https://twitter.com/intent/tweet?text=" + encodeURIComponent(document.title) + "%0a&url=" + encodeURIComponent(location.href), "tweetwindow", "width=650, height=470, personalbar=0, toolbar=0, scrollbars=1, sizable=1" ); } function LINE() { window.open( "https://social-plugins.line.me/lineit/share?url=" + encodeURIComponent(location.href), "tweetwindow", "width=650, height=470, personalbar=0, toolbar=0, scrollbars=1, sizable=1" ); } コードの解説 chrome.contextMenus.onClicked.addListener((info, tab) => {}) で、コンテキストメニューがクリックされた際の処理を指定できます。 引数の info にクリックされたコンテキストメニューの情報が、tab に現在のタブの情報が格納されています。 info.menuItemId に、先程コンテキストメニューを作成した際に指定した ID が格納されているので、それで処理を分けます。 switch 文は if ... else if ... else if ... else ... と同じような処理が簡単に行える文です。 詳細情報 chrome.scripting.executeScript で、指定したタブに指定した JavaScript のコードを挿入できます。 これを用いることで、background.js から DOM を操作することができます。 タブを指定する際にはタブの ID を用いるのですが、これは chrome.contextMenus.onClicked.addListener((info, tab) => {}) の引数 tab 内に格納されているため、tab.id で参照できます。 これが target: { tabId: tab.id } の部分に当たります。 function: で、挿入する JavaScript の関数を指定します。 これで、拡張機能を更新すると、コンテキストメニューからも各種操作が行えるようになります。 まとめ background.js でコンテキストメニューを作成できる。 background.js から DOM を操作するには、scripting 権限が必要である。chrome.contextMenus.onClicked.addListener((info, tab) => {}) で JavaScript コードを指定のタブに挿入できる。 今回扱ったコードは こちら から見られます。ぜひ参考にしてみてください。 次回予告 次回はいよいよ、作成した拡張機能を Web ストアに出品していきます。 以下のものが必要になるので、次回までに用意しておいてください。 Google アカウント(できれば開発用に新しく作ったほうが良い1) 5ドル(Web ストアに開発者登録をする際に必要) クレジットカードまたはデビットカード 本人確認を済ませた LINE プリペイドカードでも可 お疲れさまでした! 【連載一覧】 環境構築 ポップアップ作成 仕様に沿って開発 コピー機能実装 権限を減らす コンテキストメニューの作成 (←今ココ) Webストアに公開 問い合わせ用のメールアドレスが必要になります。これは公開されるので、本名を隠したい人は匿名の Google アカウントを用意しておいてください。 ↩
- 投稿日:2021-08-09T14:15:47+09:00
Chrome 拡張機能を作って公開しよう⑤ 〜権限多すぎ問題を解決〜
この記事は、先日ブログに投稿したものと同じ内容です。ぜひ、ブログの方もご覧ください。 権限を少なくする 前回まで書いてきたコードですが、まだ課題があります。 これは、Chrome の拡張機能の管理画面のスクリーンショットです。権限が、「閲覧履歴の読み取り」と、この拡張機能の機能に対して過大であることがわかります。 今回は、これを修正します。 やり方は簡単です。manifest.json の permissions を以下のように書き換えてください。 manifest.json "permissions": ["activeTab"], chrome://extensions を開き、更新ボタンを押します。 「詳細」ボタンをクリックして、「権限」欄を見てください。 「この拡張機能に特別な権限は必要ありません」と表示されていれば OK です。 不具合を修正する しかし、これだけで今日の記事が終わってしまってはあっけないので、もう一つやります。 chrome://extension 画面で拡張機能を開き、Facebook でシェアしてみてください。 エラーになります。どうやら、URL に http:// か https:// が含まれていないとリクエストを拒否する仕様になっているようです。 そこで、URL にこれらが存在するか調べる機能を実装していきます。 HTML の修正 「Facebook のシェアボタン」〜「LINE で送るボタン」までを、<div id="sns">で囲んでください。 JavaScript の修正 popup.js の chrome.tabs.query の中の末尾に以下のコードを追加してください。 popup.js if(!/http\:\/\/|https\:\/\//.test(tabUrl)) { const element = document.getElementById("sns"); element.style.marginTop = "1.5em"; element.textContent = "このページでは SNS を用いたシェア機能をご利用になれません。ご了承ください。"; } このコードに登場する test は、とある文字列の中に指定した文字列が含まれているかを、true または false で返すものです。 test の前に検索したい文字列を正規表現で指定します。正規表現について、詳しくは説明しませんが、このコードに関わっているルールを説明します。 正規表現は2つの / (スラッシュ)の間に書く | は「または」という意味。/りんご|みかん/ と書いた場合、「りんご」または「みかん」が含まれているか、という意味となる 正規表現の中に登場する /(スラッシュ)や:(コロン)、;(セミコロン)が含まれている場合、これらの文字がプログラムのコマンドと誤認識されないよう、直前に \(バッククオート)を入れる。 上記のコードの場合、「http:// か https:// が含まれているか」という意味になる。 もし、http:// も https:// も含まれていない場合、先程 popup.html に追加した<div id="sns"> の上に余白を追加し、中のテキストを変更するようになっています。 これで、SNS でシェアした際にエラーになるのを防ぐことができます。 今日は以上です! まとめ tabs の代わりに activeTab を指定することで、権限が無駄に多くなることが防げる。 test を用いると、文字列を検索することができる。 本日もお疲れさまでした! 次回は Web ストアに出品する予定でしたが、予定を変更してもう一つ機能を実装していきます。 また、今回扱ったコードは、こちらから見られます。 【連載一覧】 環境構築 ポップアップ作成 仕様に沿って開発 コピー機能実装 権限を減らす (←今ココ) コンテキストメニューの作成 Webストアに公開
- 投稿日:2021-08-09T14:15:26+09:00
Chrome 拡張機能を作って公開しよう④ 〜コピー機能の実装〜
この記事は、先日ブログに投稿したものと同じ内容です。ぜひ、ブログの方もご覧ください。 こんにちは!1回30分で拡張機能を作るシリーズ、第4弾です。今回は、この拡張機能の要、「コピー機能」を実装していきます。 「あれ?15分じゃなかったのかよ!」と気づかれた方、そうです。1回分のボリュームが15分に収まりきらないということで、誠に勝手ながら30分に延長させていただきました。 では、本題に入っていきましょう。(切り替えが早い) コピーボタンの配置 popup.html を次のように書き換えてください。 <!--ここから--> という部分から、<!--ここまで--> という部分までが追加する分です。追加する部分は2箇所あります。 popup.html <div class="text"> <input type="text" id="pageTitle" class="column" readonly> </div> <!--ここから--> <div class="copy"> <button title="ページタイトルをコピー" id="copyTitle"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16" aria-label="ページタイトルをコピー"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> </svg> </button> </div> <!--ここまで--> <!--省略--> <div class="text"> <input type="url" id="pageURL" class="column" readonly> </div> <!--ここから--> <div class="copy"> <button title="URLをコピー" id="copyURL"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16" aria-label="URLをコピー"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> </svg> </button> </div> <!--ここまで--> また、<script> タグの直前に、以下のコードを追記してください。 popup.html <div class="content"> <div class="both"> <button title="タイトルとURLをコピー" id="copyBoth"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard withText" viewBox="0 0 16 16" aria-label="タイトルとURLをコピー"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> </svg>タイトルと URL をコピー </button> </div> </div> このままではボタンがむき出しになっていてかっこ悪いので、popup.css に以下の内容を追記してください。 一応、コメントに解説のようなものを載せました。よかったら参考にしてください。 popup.css /*ボタンに適用するCSS*/ button { appearance: none; /*デフォルトの見た目を無効に*/ -webkit-appearance: none; /*あまり意味はないが、古いブラウザ向け。デフォルトの見た目を無効にする。*/ border: 0; /*枠線を消す*/ background-color: #4c3cda; /*背景色を指定*/ color: #fff;/*文字色を白に*/ padding: 8px 16px; /*内側の余白を指定*/ font-size: 16px; /*フォントサイズを16pxに*/ border-radius: 5px; /*角を丸くする*/ cursor: pointer; /*カーソルを手のひらの形状にする*/ transition: background-color .2s; /*背景色が変わるときにアニメーション*/ } button:hover { /*カーソルがボタンに乗っているとき*/ background-color: #2f22a3; /*背景色を変更*/ } button:focus { /*tabキーなどで、ボタンがフォーカスされているとき*/ outline: none; /*デフォルトの枠線を消す*/ box-shadow: 0 0 0 4px #cbd6ee; /*代わりの枠線*/ } /*ボタンの中のアイコンに適用するCSS*/ button svg { fill: #fff; /*ボタンの中のアイコンの色を白色にする*/ } svg.withText{ vertical-align: middle; /*隣接するテキストを、アイコンの上下中央にラインに配置*/ margin-right: .7em; /*右側に余白をあける*/ } ここまでやると、以下の画像のようになります。ボタンにスタイルが適用されていて、いい感じですね! コピー機能の実装 それでは、本題の「コピー機能の実装」に移ります。 流れとしては、 1. コピーボタンをクリックする 2. クリップボードに URL またはタイトルがコピーされる 「コピーボタンをクリックしたとき」は、addEventListener を使えばいけそうです。 また、クリップボードにコピーするという動作は、navigator.clipboard.writeText(コピーしたい文字列) でできます。 それでは、これを組み合わせてみましょう。popup.js に、以下のようにコードを追加してください。/*ここから*/ から、/*ここまで*/ の部分が追加するコードです。 popup.js chrome.tabs.query({ 'active': true, 'lastFocusedWindow': true }, tabs => { title.value = tabs[0].title; url.value = tabs[0].url; /*ここから*/ document.getElementById("copyTitle").addEventListener("click",()=>{ navigator.clipboard.writeText(tabs[0].title); }, false); document.getElementById("copyURL").addEventListener("click",()=>{ navigator.clipboard.writeText(tabs[0].url); }, false); document.getElementById("copyBoth").addEventListener("click",()=>{ navigator.clipboard.writeText(tabs[0].title + "\n" + tabs[0].url); }, false); /*ここまで*/ }); それでは、「コピー」ボタンを押して、動作を確認してみてください! ちなみに、「タイトルとURLを両方コピー」した場合は、タイトルとURLの間に改行が入ります。これが、上記のコードの \n に当たる部分です。JavaScriptでは、\n を使うと文字列を改行させることができます。 コピーしたことを伝える しかし、これではボタンを押しても何も起こらないので、本当にコピーされたのか疑問に思いますよね。 そこで、コピーボタンを押したら、クリップボードのアイコンが「チェックアイコン」に変わるようにしましょう。 仕組みはこうです。 コピーボタンが押されたら、クリップボードのアイコンを非表示にし、チェックアイコンを表示する 5秒後にもとに戻す ボタンがクリックされたとき、他のボタンがチェックアイコンだったらもとに戻す まずは、popup.html から変更していきます。すべてのクリップボードアイコンのあとに、以下のコードを追加してください。(クリップボードアイコンには、bi-clipboard というクラス名がついています。目印にしてください。) popup.html <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2 checkIcon" viewBox="0 0 16 16" style="display: none;"> <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/> </svg> ただ、この時点でポップアップを開いてもなんの変化もありません。先程追加したチェックアイコンには、style="display: none;" とあります。これにより、非表示になっているからです。 では、popup.js に、ボタンを押したときにチェックアイコンを表示させるようにプログラムします。 「クリップボードアイコンをチェックアイコンに入れ替える」という処理は何度か使いそうなので、関数にまとめます。 popup.js の末尾に以下のコードを追加してください。 popup.js let timeout; function showCheck(parentId) { clearTimeout(timeout); //5秒後に予定されている処理をキャンセル const checkIcons = document.getElementsByClassName("checkIcon"); for (let i = 0; i < checkIcons.length; i++) { checkIcons[i].style.display = "none"; //一旦すべてのチェックアイコンを非表示に } const clipIcons = document.getElementsByClassName("bi-clipboard"); for (let i = 0; i < clipIcons.length; i++) { clipIcons[i].style.display = ""; //一旦すべてのクリップボードアイコンを表示 } const child = document.getElementById(parentId).children; //ボタンの子要素を取得 child[0].style.display = "none"; //ボタンの子要素のうち1番目、つまりクリップボードアイコンを非表示に child[1].style.display = ""; //ボタンの子要素のうち2番目、つまりチェックアイコンを表示する timeout = setTimeout(() => { /*5秒後の処理*/ child[0].style.display = ""; //クリップボードアイコンを表示する child[1].style.display = "none"; //チェックアイコンを非表示に }, 5000); } そして、ボタンがクリックされた際にこの関数を呼び出すようにします。引数 parentId には、ボタンのIDを代入します。 navigator.clipboard.writeText() の次の行に、下記の「候補1」〜「候補3」のいずれかを追加してください。どの navigator.clipboard.writeText() の後に、どのコードを追加するのかは、是非自分で考えてみてください! popup.js /*候補1*/ showCheck("copyTitle"); /*候補2*/ showCheck("copyURL"); /*候補3*/ showCheck("copyBoth"); これで、コピーボタンをクリックするとチェックアイコンに切り替わるはずです。 もし、クリックしたのと違うボタンのアイコンが変化している場合は、上記のコードを追加する場所を間違えています。 SNS へのシェアボタンを追加する SNS で直接投稿・送信ができるボタンを設置します。 このようなボタンについては、公式が「このURLにアクセスすればシェアできるよ」と公開してくれているので、それに則って作成していきます。 まずは popup.html から行きましょう。 <script> タグの直前にある <div class="content"> の中身をすべて削除してください。そして、以下のコードに書き換えてください。 popup.html <div class="both"> <button title="タイトルとURLをコピー" id="copyBoth" class="shareSNS copyBoth"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard withText" viewBox="0 0 16 16" aria-label="タイトルとURLをコピー"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> </svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2 checkIcon withText" viewBox="0 0 16 16" style="display: none;"> <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/> </svg> タイトルと URL をコピー </button> </div> <div class="toFB"> <button title="シェア" id="shareToFB" class="FB shareSNS"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-facebook withText" viewBox="0 0 16 16" aria-label="Facebook"> <path d="M16 8.049c0-4.446-3.582-8.05-8-8.05C3.58 0-.002 3.603-.002 8.05c0 4.017 2.926 7.347 6.75 7.951v-5.625h-2.03V8.05H6.75V6.275c0-2.017 1.195-3.131 3.022-3.131.876 0 1.791.157 1.791.157v1.98h-1.009c-.993 0-1.303.621-1.303 1.258v1.51h2.218l-.354 2.326H9.25V16c3.824-.604 6.75-3.934 6.75-7.951z"/> </svg> シェア </button> </div> <div class="toTwitter"> <button title="ツイート" id="tweet" class="twitter shareSNS"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-twitter withText" viewBox="0 0 16 16" aria-label="Twitter"> <path d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"/> </svg> ツイート </button> </div> <div class="toLINE"> <button title="LINE で送る" id="LINE" class="LINE shareSNS"> <svg class="withText" xmlns="http://www.w3.org/2000/svg" width="19" height="19" fill="currentColor" viewBox="0 0 448 512" aria-label="LINEで送る"> <!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --> <path d="M272.1 204.2v71.1c0 1.8-1.4 3.2-3.2 3.2h-11.4c-1.1 0-2.1-.6-2.6-1.3l-32.6-44v42.2c0 1.8-1.4 3.2-3.2 3.2h-11.4c-1.8 0-3.2-1.4-3.2-3.2v-71.1c0-1.8 1.4-3.2 3.2-3.2H219c1 0 2.1.5 2.6 1.4l32.6 44v-42.2c0-1.8 1.4-3.2 3.2-3.2h11.4c1.8-.1 3.3 1.4 3.3 3.1zm-82-3.2h-11.4c-1.8 0-3.2 1.4-3.2 3.2v71.1c0 1.8 1.4 3.2 3.2 3.2h11.4c1.8 0 3.2-1.4 3.2-3.2v-71.1c0-1.7-1.4-3.2-3.2-3.2zm-27.5 59.6h-31.1v-56.4c0-1.8-1.4-3.2-3.2-3.2h-11.4c-1.8 0-3.2 1.4-3.2 3.2v71.1c0 .9.3 1.6.9 2.2.6.5 1.3.9 2.2.9h45.7c1.8 0 3.2-1.4 3.2-3.2v-11.4c0-1.7-1.4-3.2-3.1-3.2zM332.1 201h-45.7c-1.7 0-3.2 1.4-3.2 3.2v71.1c0 1.7 1.4 3.2 3.2 3.2h45.7c1.8 0 3.2-1.4 3.2-3.2v-11.4c0-1.8-1.4-3.2-3.2-3.2H301v-12h31.1c1.8 0 3.2-1.4 3.2-3.2V234c0-1.8-1.4-3.2-3.2-3.2H301v-12h31.1c1.8 0 3.2-1.4 3.2-3.2v-11.4c-.1-1.7-1.5-3.2-3.2-3.2zM448 113.7V399c-.1 44.8-36.8 81.1-81.7 81H81c-44.8-.1-81.1-36.9-81-81.7V113c.1-44.8 36.9-81.1 81.7-81H367c44.8.1 81.1 36.8 81 81.7zm-61.6 122.6c0-73-73.2-132.4-163.1-132.4-89.9 0-163.1 59.4-163.1 132.4 0 65.4 58 120.2 136.4 130.6 19.1 4.1 16.9 11.1 12.6 36.8-.7 4.1-3.3 16.1 14.1 8.8 17.4-7.3 93.9-55.3 128.2-94.7 23.6-26 34.9-52.3 34.9-81.5z"></path> </svg> LINE で送る </button> </div> しかし、これではレイアウトやデザインがメチャクチャなので、CSS で整えます。popup.css の末尾に以下のコードを追加してください。 popup.css .shareSNS{ background-color: #fff; width: 100%; text-align: center; margin: .3em 0; border-width: 2px; border-style: solid; } .copyBoth{ color: #4c3cda; border-color: #4c3cda; } .copyBoth svg{ fill: #4c3cda; } .copyBoth:hover{ background-color: #4c3cda; } .FB{ color: #1877f2; border-color: #1877f2; } .FB svg{ fill: #1877f2; } .FB:hover{ background-color: #1877f2; } .twitter{ color: #1DA1F2; border-color: #1DA1F2; } .twitter svg{ fill: #1DA1F2; } .twitter:hover{ background-color: #1DA1F2; } .LINE{ color: #00B900; border-color: #00B900; } .LINE svg{ fill: #00B900; } .LINE:hover{ background-color: #00B900; } .shareSNS:hover{ color: #fff; } .shareSNS:hover svg{ fill: #fff; } 下の画像のようになっていればOKです。 では、popup.js で動きを付け加えていきます。 これと同時に、tabs[0].title と tabs[0].url を定数に格納します。後で参照しやすくするためです。 ということで、chrome.tabs.query の中身の先頭に以下のコードを追加してください。 popup.js const tabTitle = tabs[0].title; const tabUrl = tabs[0].url; そして、コードの中の tabs[0].title と tabs[0].url をそれぞれ、tabTitle と tabUrl に書き換えてください。 その後、以下のコードを chrome.tabs.query の中身の末尾に追加してください。 popup.js /*Facebook*/ document.getElementById("shareToFB").addEventListener("click", () => { windowOpen("https://www.facebook.com/share.php?u=" + encodeURIComponent(tabUrl)); }, false); /*Twitter*/ document.getElementById("tweet").addEventListener("click", () => { windowOpen("https://twitter.com/intent/tweet?text=" + encodeURIComponent(tabTitle) + "%0a&url=" + encodeURIComponent(tabUrl)); }, false); /*LINE*/ document.getElementById("LINE").addEventListener("click", ()=>{ windowOpen("https://social-plugins.line.me/lineit/share?url=" + encodeURIComponent(tabUrl)); }, false); 下の関数も定義してください。これは、ポップアップを開く際に用いられています。引数 url がポップアップを開くURLに当たります。 popup.js function windowOpen(url) { window.open(url, 'tweetwindow', 'width=650, height=470, personalbar=0, toolbar=0, scrollbars=1, sizable=1') this.close(); return false; } では、SNS シェア部分のコードの解説をします。 主要SNS では、指定された URL にアクセスすることで、シェアができるようになっています。SNS サービス毎の URL は以下のとおりです。 サービス名 URL Facebook https://www.facebook.com/share.php?u={{ URL }} Twitter https://twitter.com/share?text={{ ツイート文 }}&url={{ URL }}&hashtags={{ ハッシュタグ (カンマ区切りで複数指定可能) }} LINE https://social-plugins.line.me/lineit/share?url={{ URL }} {{ URL }} の部分に共有したい URL を入れると、上記のような SNS でシェアすることができます。 また、Twitter の場合は、ツイート文やハッシュタグも指定できます。要領は URL と同じで、指定された部分に文字を入れるだけです。 上記のコードの中に、encodeURIComponent() というものが頻繁に使われていますが、これは日本語や記号などを、コンピューターが扱いやすいように変換するためのものです。URL の中に日本語などが紛れていると、予期せぬトラブルの原因になります。そこで、この処理が必要になるわけです。 コード全体 最後に、コードの全体を提示します。「うまく動かない」等の場合は参考にしてみてください。 popup.html <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <link rel="stylesheet" type="text/css" href="popup.css"> </head> <body> <div class="content"> <label for="pageTitle" class="column">タイトル</label> <div class="grid"> <div class="text"> <input type="text" id="pageTitle" class="column" readonly> </div> <div class="copy"> <button title="ページタイトルをコピー" id="copyTitle"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16" aria-label="ページタイトルをコピー"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> </svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2 checkIcon" viewBox="0 0 16 16" style="display: none;"> <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/> </svg> </button> </div> </div> </div> <div class="content"> <label for="pageURL" class="column">URL</label> <div class="grid"> <div class="text"> <input type="url" id="pageURL" class="column" readonly> </div> <div class="copy"> <button title="URLをコピー" id="copyURL"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16" aria-label="URLをコピー"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> </svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2 checkIcon" viewBox="0 0 16 16" style="display: none;"> <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/> </svg> </button> </div> </div> </div> <div class="content"> <div class="both"> <button title="タイトルとURLをコピー" id="copyBoth" class="shareSNS copyBoth"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard withText" viewBox="0 0 16 16" aria-label="タイトルとURLをコピー"> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> </svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2 checkIcon withText" viewBox="0 0 16 16" style="display: none;"> <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/> </svg> タイトルと URL をコピー </button> </div> <div class="toFB"> <button title="シェア" id="shareToFB" class="FB shareSNS"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-facebook withText" viewBox="0 0 16 16" aria-label="Facebook"> <path d="M16 8.049c0-4.446-3.582-8.05-8-8.05C3.58 0-.002 3.603-.002 8.05c0 4.017 2.926 7.347 6.75 7.951v-5.625h-2.03V8.05H6.75V6.275c0-2.017 1.195-3.131 3.022-3.131.876 0 1.791.157 1.791.157v1.98h-1.009c-.993 0-1.303.621-1.303 1.258v1.51h2.218l-.354 2.326H9.25V16c3.824-.604 6.75-3.934 6.75-7.951z"/> </svg> シェア </button> </div> <div class="toTwitter"> <button title="ツイート" id="tweet" class="twitter shareSNS"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-twitter withText" viewBox="0 0 16 16" aria-label="Twitter"> <path d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"/> </svg> ツイート </button> </div> <div class="toLINE"> <button title="LINE で送る" id="LINE" class="LINE shareSNS"> <svg class="withText" xmlns="http://www.w3.org/2000/svg" width="19" height="19" fill="currentColor" viewBox="0 0 448 512" aria-label="LINEで送る"> <!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --> <path d="M272.1 204.2v71.1c0 1.8-1.4 3.2-3.2 3.2h-11.4c-1.1 0-2.1-.6-2.6-1.3l-32.6-44v42.2c0 1.8-1.4 3.2-3.2 3.2h-11.4c-1.8 0-3.2-1.4-3.2-3.2v-71.1c0-1.8 1.4-3.2 3.2-3.2H219c1 0 2.1.5 2.6 1.4l32.6 44v-42.2c0-1.8 1.4-3.2 3.2-3.2h11.4c1.8-.1 3.3 1.4 3.3 3.1zm-82-3.2h-11.4c-1.8 0-3.2 1.4-3.2 3.2v71.1c0 1.8 1.4 3.2 3.2 3.2h11.4c1.8 0 3.2-1.4 3.2-3.2v-71.1c0-1.7-1.4-3.2-3.2-3.2zm-27.5 59.6h-31.1v-56.4c0-1.8-1.4-3.2-3.2-3.2h-11.4c-1.8 0-3.2 1.4-3.2 3.2v71.1c0 .9.3 1.6.9 2.2.6.5 1.3.9 2.2.9h45.7c1.8 0 3.2-1.4 3.2-3.2v-11.4c0-1.7-1.4-3.2-3.1-3.2zM332.1 201h-45.7c-1.7 0-3.2 1.4-3.2 3.2v71.1c0 1.7 1.4 3.2 3.2 3.2h45.7c1.8 0 3.2-1.4 3.2-3.2v-11.4c0-1.8-1.4-3.2-3.2-3.2H301v-12h31.1c1.8 0 3.2-1.4 3.2-3.2V234c0-1.8-1.4-3.2-3.2-3.2H301v-12h31.1c1.8 0 3.2-1.4 3.2-3.2v-11.4c-.1-1.7-1.5-3.2-3.2-3.2zM448 113.7V399c-.1 44.8-36.8 81.1-81.7 81H81c-44.8-.1-81.1-36.9-81-81.7V113c.1-44.8 36.9-81.1 81.7-81H367c44.8.1 81.1 36.8 81 81.7zm-61.6 122.6c0-73-73.2-132.4-163.1-132.4-89.9 0-163.1 59.4-163.1 132.4 0 65.4 58 120.2 136.4 130.6 19.1 4.1 16.9 11.1 12.6 36.8-.7 4.1-3.3 16.1 14.1 8.8 17.4-7.3 93.9-55.3 128.2-94.7 23.6-26 34.9-52.3 34.9-81.5z"></path> </svg> LINE で送る </button> </div> </div> <script src="popup.js" defer></script> </body> </html> popup.css body { letter-spacing: 0.15em; width: 400px; font-size: 16px; } * { color: #606c76; box-sizing: border-box; font-family: -apple-system, blinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Arial", "Roboto", "Hiragino Kaku Gothic ProN", YuGothicM, YuGothic, Meiryo, "Noto Sans JP", sans-serif; } @font-face { font-family: YuGothicM; font-weight: normal; src: local("YuGothic-Medium"), local("Yu Gothic Medium"), local("YuGothic-Regular"); /* Windows8.1ではMediumがないのでRegularを指定 */ } @font-face { font-family: YuGothicM; font-weight: bold; src: local("YoGothic-Bold"), local("Yu Gothic"); } .content { margin: 2em 0; } .grid { display: grid; grid-template-columns: 1fr auto; gap: 1.2em 1em; width: 100%; margin-top: 0.3em; } .text { position: relative; } .text input { color: #727d86; font: 16px sans-serif; width: 100%; padding: 0; padding-bottom: 0.1em; letter-spacing: 1px; border: 0; } .text input:focus { outline: none; } .text input:focus::after { outline: none; } .text::after { display: block; width: 100%; height: 1px; margin-top: 0; content: ""; border-width: 0 1px 1px 1px; border-style: solid; border-color: #4c3cda; } .text .column { width: 100%; height: 100%; } button { appearance: none; -webkit-appearance: none; border: 0; background-color: #4c3cda; color: #fff; padding: 8px 16px; font-size: 16px; border-radius: 5px; cursor: pointer; transition: background-color .2s; } button:hover { background-color: #2f22a3; } button:focus { outline: none; box-shadow: 0 0 0 4px #cbd6ee; } button svg { fill: #fff; } svg.withText{ vertical-align: middle; margin-right: .7em; } .shareSNS{ background-color: #fff; width: 100%; text-align: center; margin: .3em 0; border-width: 2px; border-style: solid; } .copyBoth{ color: #4c3cda; border-color: #4c3cda; } .copyBoth svg{ fill: #4c3cda; } .copyBoth:hover{ background-color: #4c3cda; } .FB{ color: #1877f2; border-color: #1877f2; } .FB svg{ fill: #1877f2; } .FB:hover{ background-color: #1877f2; } .twitter{ color: #1DA1F2; border-color: #1DA1F2; } .twitter svg{ fill: #1DA1F2; } .twitter:hover{ background-color: #1DA1F2; } .LINE{ color: #00B900; border-color: #00B900; } .LINE svg{ fill: #00B900; } .LINE:hover{ background-color: #00B900; } .shareSNS:hover{ color: #fff; } .shareSNS:hover svg{ fill: #fff; } popup.js const title = document.getElementById("pageTitle"); const url = document.getElementById("pageURL"); chrome.tabs.query({ 'active': true, 'lastFocusedWindow': true }, tabs => { const tabTitle = tabs[0].title; const tabUrl = tabs[0].url; title.value = tabTitle; url.value = tabUrl; document.getElementById("copyTitle").addEventListener("click", () => { navigator.clipboard.writeText(tabTitle); showCheck("copyTitle"); }, false); document.getElementById("copyURL").addEventListener("click", () => { navigator.clipboard.writeText(tabUrl); showCheck("copyURL"); }, false); document.getElementById("copyBoth").addEventListener("click", () => { navigator.clipboard.writeText(tabTitle + "\n" + tabUrl); showCheck("copyBoth"); }, false); document.getElementById("shareToFB").addEventListener("click", () => { windowOpen("https://www.facebook.com/share.php?u=" + encodeURIComponent(tabUrl)); }, false); document.getElementById("tweet").addEventListener("click", () => { windowOpen("https://twitter.com/intent/tweet?text=" + encodeURIComponent(tabTitle) + "%0a&url=" + encodeURIComponent(tabUrl)); }, false); document.getElementById("LINE").addEventListener("click", ()=>{ windowOpen("https://social-plugins.line.me/lineit/share?url=" + encodeURIComponent(tabUrl)); }, false); }); let timeout; function showCheck(parentId) { clearTimeout(timeout); const checkIcons = document.getElementsByClassName("checkIcon"); for (let i = 0; i < checkIcons.length; i++) { checkIcons[i].style.display = "none"; } const clipIcons = document.getElementsByClassName("bi-clipboard"); for (let i = 0; i < clipIcons.length; i++) { clipIcons[i].style.display = ""; } const child = document.getElementById(parentId).children; child[0].style.display = "none"; child[1].style.display = ""; timeout = setTimeout(() => { child[0].style.display = ""; child[1].style.display = "none"; }, 5000); } function windowOpen(url) { window.open(url, 'tweetwindow', 'width=650, height=470, personalbar=0, toolbar=0, scrollbars=1, sizable=1') this.close(); return false; } さて、今回はここまでです。お疲れさまでした! TODO 前回予告した、「権限を少なくする作業」(次回) Chrome Web ストアで公開(次々回) ※今後の予定は変更になる可能性があります まとめ navigator.clipboard.writeText(コピーしたい文字列) で文字列をクリップボードにコピーできる。 指定のURLにアクセスすれば、各種SNSでシェアすることができる。 今回は前回以上にボリューミーでしたが、ここまで読んでいただきありがとうございました。 また、今回扱ったコードは、こちらから見られます。 【連載一覧】 環境構築 ポップアップ作成 仕様に沿って開発 コピー機能実装 (←今ココ) 権限を減らす コンテキストメニューの作成 Webストアに公開
- 投稿日:2021-08-09T14:15:06+09:00
Chrome 拡張機能を作って公開しよう③ 〜形にする〜
この記事は、先日ブログに投稿したものと同じ内容です。ぜひ、ブログの方もご覧ください。 こんにちは。1回15分で拡張機能を作るシリーズ、第3弾です。 今回は、前回作成したファイルをもとに、拡張機能を形にしていきます。 今回はボリュームが大きいので、15分で終わらないかもしれません。その時はごめんなさい。 仕様 まずは今回の拡張機能の仕様を紹介します。 2つの独立したテキストボックスに、URL・ページタイトルをそれぞれ代入する URL・ページタイトルは、それぞれコピーできる URLとページタイトルを両方コピーすることもできる Facebook や Twitter、LINE へのシェア機能も実装する とりあえずこのような仕様でいきます。 ポップアップの作成 前回作ったものは、「Hello, world」と出力するだけのものでしたが、今回は更に拡張機能っぽくしていきます。 なお、今から記述していく HTML と CSS は簡単なものなのでコピペで済ませて構いません。後半の JavaScript が難しいので... では、まずは HTML から。popup.htmlの中身をすべて消して、以下の内容を記述してください。 popup.html <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <link rel="stylesheet" type="text/css" href="popup.css"> </head> <body> <div class="content"> <label for="pageTitle" class="column">タイトル</label> <div class="grid"> <div class="text"> <input type="text" id="pageTitle" class="column" readonly> </div> </div> </div> <div class="content"> <label for="pageURL" class="column">URL</label> <div class="grid"> <div class="text"> <input type="url" id="pageURL" class="column" readonly> </div> </div> </div> <script src="popup.js" defer></script> </body> </html> HTML を保存したら、ポップアップを開いてみてください。 以下のようになっていれば OK です。 しかし、これでは味気ないので、CSS の登場です!popup.cssの中身をすべて消して、以下の内容を記述してください。 popup.css body { letter-spacing: .15em; width: 400px; font-size: 16px; } * { color: #606c76; box-sizing: border-box; font-family: -apple-system, blinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Arial", "Roboto", "Hiragino Kaku Gothic ProN", YuGothicM, YuGothic, Meiryo, 'Noto Sans JP', sans-serif; } @font-face { font-family: YuGothicM; font-weight: normal; src: local("YuGothic-Medium"), local("Yu Gothic Medium"), local("YuGothic-Regular"); /* Windows8.1ではMediumがないのでRegularを指定 */ } @font-face { font-family: YuGothicM; font-weight: bold; src: local("YoGothic-Bold"), local("Yu Gothic"); } .content{ margin: 2em 0; } .grid { display: grid; grid-template-columns: 1fr auto; gap: 1.2em 1em; width: 100%; margin-top: .3em; } .text { position: relative; } .text input { color: #727d86; font: 16px sans-serif; width: 100%; padding: 0; padding-bottom: .1em; letter-spacing: 1px; border: 0; } .text input:focus { outline: none; } .text input:focus::after { outline: none; } .text::after { display: block; width: 100%; height: 1px; margin-top: 0; content: ''; border-width: 0 1px 1px 1px; border-style: solid; border-color: #4c3cda; } .text .column { width: 100%; height: 100%; } これでポップアップを開いてみると...? いい感じになっています! JavaScript で動きをつける さあ、続いて JavaScript の記述です!まずは、popup.jsの中身を消して、「タイトル」を表示させる要素と「URL」を表示させる要素を取得して定数に代入します。 popup.js const title = document.getElementById("pageTitle"); const url = document.getElementById("pageURL"); テキストボックスにテキストを代入するには、element.value = (代入したい文字列) を使えばよいのでした。きっと多くの人が下記のプログラムを思いついたでしょう。 const title = document.getElementById("pageTitle"); const url = document.getElementById("pageURL"); /* ココから追記分 */ title.value = document.title;// タイトルを代入 url.value = location.href; //URLを代入 /* ココまで追記分 */ しかし、実行してみると... このように、このポップアップの HTML の URL とタイトルが代入されてしまいます。 (URL は人によって異なるかもしれません。また、ポップアップに <title> タグが指定されていないため、「タイトル」欄は空白になっています。) そこで、用いるのが、chrome.tabs.queryメソッドです!これは、Chrome の拡張機能のみが使用することができるメソッドとなっています。 このメソッドを使うためには、manifest.jsonに以下の内容を書き加えて、「この拡張機能は開いているタブの情報を必要とするから教えてくれ」ということをブラウザに伝える必要があります。 manifest.json "permissions": [ "tabs" ], このメソッドを用いてプログラムを書き換えると、以下のようになります。「ここは削除!」とコメントしてある部分は、削除してください。 popup.js const title = document.getElementById("pageTitle"); const url = document.getElementById("pageURL"); /*========================= ここは削除! title.value = document.title;// タイトルを代入 url.value = location.href; //URLを代入 ===========================*/ /*ココから追記分*/ chrome.tabs.query({ 'active': true, 'lastFocusedWindow': true }, tabs => { title.value = tabs[0].title; url.value = tabs[0].url; }); では、chrome.tabs.query について詳しく説明していきます。 chrome.tabs.queryは、条件に合うタブの情報を、NodeList 形式で返してくれるメソッドです。NodeList がよくわからない人は、普通の JavaScript の配列やオブジェクトと同じようなものと覚えておいてください。 chrome.tabs.query({/*条件を記述*/}, tabs => { }); 例えば、上記のプログラムを実行した場合、/*条件を記述*/ のところに記述した条件に合うタブの情報が格納された NodeList が、tabsに代入されます。 tabs => {} という書き方を初めて見る方もいらっしゃるかもしれませんが、function (tabs) {}と同じです。これは「アロー関数」と呼ばれるものです。気になる方は調べてみてください。 それでは、肝心の条件式について見ていきます。 条件式はオブジェクト形式で、{項目1: true/false, 項目2: true/false, ...} のように書いていきます。 主な「項目」は以下のとおりです。 項目 説明 active ウインドウ中のアクティブなタブか currentWindow 現在のウインドウにあるタブか muted ミュートになっているタブか pinned 固定されているタブか lastFocusedWindow 最後にフォーカスしたウインドウにあるか その他の「項目」については、以下の公式サイトをご覧ください。 tabs[0].url で、条件に適したタブのうち1番目のURLを得ることができ、tabs[0].title で、条件に適したタブのうち1番目のタイトルを得ることができます。 この NodeList は「オブジェクト」の「配列」になっているため、配列の1番目(といっても配列の長さは1だけですが)であることを明確にする必要があります。 ここで、先程のコードを読み返してみましょう。 popup.js chrome.tabs.query({ 'active': true, 'lastFocusedWindow': true }, tabs => { title.value = tabs[0].title; url.value = tabs[0].url; }); 条件の部分は、{ 'active': true, 'lastFocusedWindow': true }となっています。これは、「ウインドウの中のアクティブなタブ」かつ「最後に開いたウインドウの中にあるタブ」ということなので、「現在見ているタブ」を指していることが分かります。 ということで、JavaScript のコード全体は以下のようになります。 popup.js const title = document.getElementById("pageTitle"); const url = document.getElementById("pageURL"); chrome.tabs.query({ 'active': true, 'lastFocusedWindow': true }, tabs => { title.value = tabs[0].title; url.value = tabs[0].url; }) このコードを動かしてみると... いい感じですね! では、今回はこの辺にして、次回はメインの「コピー機能」と SNS への「シェア機能」を実装していきます。お楽しみに! 問題点と予告 実は、このコードにはまだ問題があります。この画像は、Chrome の拡張機能の管理画面のスクリーンショットです。 この画像からわかるように、権限が多すぎるのです。この程度のプログラムであれば、「閲覧履歴の読み取り」をしなくとも実装できますが、結果的にユーザーを不安にさせてしまうような権限になってしまいました。これについては、次々回で対処していきます! まとめ chrome.tabs.query でタブの検索が行える。 お疲れさまでした! なお、今回扱ったコードは、こちらから見られます。 【連載一覧】 環境構築 ポップアップ作成 仕様に沿って開発 (←今ココ) コピー機能実装 権限を減らす コンテキストメニューの作成 Webストアに公開
- 投稿日:2021-08-09T14:14:38+09:00
Chrome 拡張機能を作って公開しよう② 〜ポップアップの作成〜
この記事は、先日ブログに投稿したものと同じ内容です。ぜひ、ブログの方もご覧ください。 拡張機能を作るシリーズ第2弾!今回は拡張機能のポップアップを作成していきます! これでかなり拡張機能っぽくなります。 ポップアップとは 拡張機能のアイコンをクリックした時に出てくるもののことです。ここから拡張機能の設定や操作を行うことができます。拡張機能を使ったことがあれば、一度は目にしたことがあるでしょう。 実は、このポップアップは、HTMLとCSSでできています! ということで、サクッと作っちゃいましょう! 今回作っていくもの 前回、「作るものはお楽しみ」と言っておきましたが、今回作っていく拡張機能が、ついに決まりました。 それは、「開いているページのタイトルとURLをコピーする拡張機能」です。 イメージはこんな感じです。(汚くてすみません)↓ では、早速作っていきましょう! 設定ファイルを作る ブラウザに、「これはこのような拡張機能ですよ」と教えてあげるための、設定ファイルを作っていきます。 拡張機能開発用のフォルダを作って、manifest.json を作成してください。その後、下のコードを記述してください。 manifest.json { "name": "タイトルとURLをコピー", "subheading": "ページのタイトルとURLを簡単にコピーできます。", "version": "1.0", "manifest_version": 3, "icons": { "16": "icon_16x16.png", "48": "icon_48x48.png", "128": "icon_128x128.png" }, "action": { "default_title": "タイトルとURLをコピー", "default_icon": "icon_48x48.png", "default_popup": "popup.html" } } では、このmanifest.jsonについて解説していきます。 名称 説明 name 拡張機能の名称 subheading 拡張機能の説明。ブラウザの「拡張機能の管理」ページなどに表示される。 version 拡張機能のバージョン。 1.0→1.0.1→1.0.2→...→1.1→...→2.0 のように新しくなっていく。アップデートのたびにバージョン番号を新しくしていく。 manifest_version この設定ファイル(manifest.json)の仕様のバージョン。ここでは最新の 3 を指定。 icons 拡張機能のアイコン。16x16、48x48、128x128の3種類の大きさを用意しなくてはならない。アイコンの画像は後ほど配布します。 action ポップアップに関する設定。default_titleで、拡張機能のアイコンにカーソルをホバーした時に表示されるテキストを指定。default_iconで、ブラウザのURLバーの右側に表示される、拡張機能のアイコンを指定。ここでは、先程のiconsで指定した48x48の画像を流用。default_popupで、ポップアップとして表示させるHTMLファイルを指定。 アイコン アイコンは下のリンクからダウンロードできます。 .zip形式ですので、解凍して拡張機能の開発用フォルダの一番上の階層に、3つの画像を配置してください。 ダウンロード 今回は、「ICOOON MONO」様からお借りしました。この場をお借りして御礼申し上げます。 本家様 なお、現在のフォルダの中身はこのようになっています。 HTML ファイルの作成 それでは、HTML ファイルを記述してポップアップを作っていきます。 開発用フォルダの一番上の階層に、popup.html を作成し、以下のコードを記述してください。 popup.html <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <link rel="stylesheet" type="text/css" href="popup.css"> </head> <body> <p class="hello">Hello, world</p> <script src="popup.js" defer></script> </body> </html> 同様に、popup.cssも作成し、以下のコードを記述してください。 popup.css body{ width: 400px; } .hello{ color: #333; font-size: 30px; } 最後に、popup.jsを作成し、以下のコードを記述してください。 popup.js setTimeout(() => { alert("Hello, world! (from js)"); }, 700); 現時点でのフォルダの中身はこのようになっています。 テストする ここまで、 設定ファイル(manifest.json) HTMLファイル(popup.html) CSSファイル(popup.css) JavaScriptファイル(popup.js) を作成してきました。 それでは、ちゃんと動作するかテストしてみましょう。 テストの手順は以下のとおりです。 Chrome で chrome://extensions を開く 画面右上の「デベロッパーモード」をオン 「パッケージ化されていない拡張機能を読み込む」 拡張機能の開発用フォルダを「開く」 このように、拡張機能がリストに追加されていれば成功です。 リストに追加されていることを確認できたら、URLバーの右側にある「パズルのピースのマーク」をクリックすると表示される拡張機能の一覧に、「タイトルとURLをコピー」という拡張機能が追加されているはずなので、クリックして開いてみてください。 このようなものが表示された後、「Hello, world! (from js)」というアラートが表示されたら完璧です! なお、拡張機能の一覧のピンのマークをクリックすると、URLバーの右側に固定されるので、今後テストをする際に便利です。 まとめ ポップアップ付き拡張機能の作成には、manifest.json、HTMLファイル、JavaScript ファイル、CSS ファイルが必要である。 manifest.jsonは拡張機能の情報を、ブラウザに伝えるためのファイルである。 HTML、CSS、JavaScript でポップアップを作成する。ここは、普通のWebアプリと変わらない。 Chromeの「デベロッパーモード」を使えば、拡張機能を簡単にテストできる。 いかがでしたか? 次回は、このポップアップを更に充実させていきます! なお、今回扱ったコードは、こちらから見られます。 【連載一覧】 環境構築 ポップアップ作成 (←今ココ) 仕様に沿って開発 コピー機能実装 権限を減らす コンテキストメニューの作成 Webストアに公開