373’s blog

株式会社ビズオーシャン

SendGridのWebAPIでメールを送信した時にWebhookとの照合用IDを取得する

やりたいこと

SendGridのWebAPIコール時に、あとから送信結果照合するためのIDを取得する。
取得したIDの使い方は別記事で。

tminami.hatenablog.com

APIコール時のレスポンスヘッダー

Server: nginx
Date: Tue, 31 Jul 2018 12:31:58 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 0
Connection: close
X-Message-Id: HOge_UnHmSNCCgV9PeqHuGa
Access-Control-Allow-Origin: https://sendgrid.api-docs.io
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type, On-behalf-of, x-sg-elas-acl
Access-Control-Max-Age: 600
X-No-CORS-Reason: https://sendgrid.com/docs/Classroom/Basics/API/cors.html

この中の『X-Message-Id』をDBに保持しておく。

SendGridのWebhookをGoogle Cloud Functionsで受けてCloud Datastoreに貯める

やりたいこと

現在開発中のサービスではメール送信にSendGridを利用している 。
ただ、webAPIにメール送信リクエストを送って終わっているので、送信時のエラーはわかるが宛先間違いや受信メールサーバ側でのブロックまで検知できていない。
まずはSendGridのWebhookをサーバレスで貯めるところまで進める。

途中までは以下と同じ
SendGrid のチュートリアル

Cloud Functionsの設定

関数作成

名前
(例)sendgrid-web-hook
※エンドポイントのURLになるのでわかりやすく
メモリ
とりあえず128MB
トリガー
HTTP
ソースコード
インラインエディタ
ブラウザで完結するのでお手軽
ランタイム
Node.js 8
※async/awaitがデフォルトで使えるようになっている
リージョン
asia-northeast1
タイムアウト
60秒
環境変数
Basic認証とCloudstore操作用に以下の3つを設定
  • BASIC_USERNAME
  • BASIC_PASSWORD
  • PROJECT_ID

SendGridの設定

ログインしてダッシュボードへ
「Settings」>「Mail Settings」>「Event Notification」>「HTTP POST URL」
ここにCloud Functionsで作成した関数のエンドポイントを指定する
以下の記法でBasic認証も使える
http(s)://username:password@endpoint

index.js

const Buffer = require('safe-buffer').Buffer;
const Datastore = require('@google-cloud/datastore');

const BASIC_USERNAME = process.env.BASIC_USERNAME;
const BASIC_PASSWORD = process.env.BASIC_PASSWORD;
const PROJECT_ID = process.env.PROJECT_ID;
const datastore = Datastore({projectId: PROJECT_ID});

/**
 * Verify that the webhook request came from sendgrid.
 *
 * @param {string} authorization The authorization header of the request, e.g. "Basic ZmdvOhJhcg=="
 */
function verifyWebhook (authorization) {
  const basicAuth = Buffer.from(authorization.replace('Basic ', ''), 'base64').toString();
  const parts = basicAuth.split(':');
  if (parts[0] !== BASIC_USERNAME || parts[1] !== BASIC_PASSWORD) {
    const error = new Error('Invalid credentials');
    error.code = 401;
    throw error;
  }
}

/**
 * Receive a webhook from SendGrid.
 *
 * See https://sendgrid.com/docs/API_Reference/Webhooks/event.html
 *
 * @param {object} req Cloud Function request context.
 * @param {object} res Cloud Function response context.
 */
exports.sendgridWebhook = async (req, res) => {
  try {
    if (req.method !== 'POST') {
      const error = new Error('Only POST requests are accepted');
      error.code = 405;
      throw error;
    }
    await verifyWebhook(req.get('authorization') || '');
    let events = req.body || [];
    const sgMessageId = events[0]["sg_message_id"];
    const key = datastore.key(['sg_events', sgMessageId.match(/^(.*?)\.filter.*/)[1]]);
    const results = await datastore.get(key);
    if (results[0] !== undefined) {
      events = events.concat(results[0]['values']);
    }
    const entity = {
      key: key,
      data: {"values": events}
    };
    await datastore.upsert(entity);
    res.status(200).end()
  } catch (err) {
    console.error(err);
    res.status(err.code || 500).send(err);
  };
};

package.json

{
  "name": "sendgrid-webhook",
  "version": "0.0.1",
  "main": "index.js",
  "dependencies": {
    "safe-buffer": "^5.1.2",
    "@google-cloud/datastore": "1.3.4"
  }
}

今後

とりあえずsg_message_idをキーにして配列にイベントをつめこんだ。
sg_message_idをキーにすると1回のメール送信リクエストごとの分類になる。
※『[共通部分].filter[メールアドレスごとに個別部分]』の形で入っている(おそらく)ので正規表現で共通部分だけ取り出してキーにしている。
WebhookがPOSTするデータ配列は、複数のイベントがあっても同じsg_message_idだったので、
上記の簡略化したコードになったが、もし複数リクエストが一緒にPOSTされることがあるならイベントごとに保存したほうがいいかもしれない。
書き込み制限1回/1秒もあるのでエンティティの更新はしない方がよさそう。
サービス側から叩く状態確認用APIもまた後日記事にする。

Dockerでpuppeteerを動かしてフォーム予約をする

やりたいこと

結婚式に参列している最中にブラウザから申込フォームの操作をしなければいけなくなったので
crontabを使って指定日時にDockerを立ち上げ、puppeteerを操作して華麗に予約を遂行する

作業ディレクトリの作成

mkdir -p yoyaku/app/script  
cd yoyaku

ディレクトリ構成

yoyaku/
   ├ app/
   |  └ script/
   |       └yoyaku.js
   └ Dockerfile

Dockerfile

FROM node:9.2.0

RUN apt-get update \
 && apt-get install -y \
      gconf-service \
      libasound2 \
      libatk1.0-0 \
      libc6 \
      libcairo2 \
      libcups2 \
      libdbus-1-3 \
      libexpat1 \
      libfontconfig1 \
      libgcc1 \
      libgconf-2-4 \
      libgdk-pixbuf2.0-0 \
      libglib2.0-0 \
      libgtk-3-0 \
      libnspr4 \
      libpango-1.0-0 \
      libpangocairo-1.0-0 \
      libstdc++6 \
      libx11-6 \
      libx11-xcb1 \
      libxcb1 \
      libxcomposite1 \
      libxcursor1 \
      libxdamage1 \
      libxext6 \
      libxfixes3 \
      libxi6 \
      libxrandr2 \
      libxrender1 \
      libxss1 \
      libxtst6 \
      ca-certificates \
      fonts-liberation \
      libappindicator1 \
      libnss3 \
      lsb-release \
      xdg-utils \
      wget

RUN mkdir -p /puppeteer/app/script
WORKDIR /yoyaku/app
# ローカルからコンテナへ実行ファイルが入ったディレクトリをコピー
COPY app/script /yoyaku/app/script
RUN npm install puppeteer

# スクリーンショットをとるときに文字化けしないようフォントをインストール
RUN mkdir /noto
ADD https://noto-website.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip /noto
WORKDIR /noto
RUN apt-get install -y unzip
RUN unzip NotoSansCJKjp-hinted.zip && \
    mkdir -p /usr/share/fonts/noto && \
    cp *.otf /usr/share/fonts/noto && \
    chmod 644 -R /usr/share/fonts/noto/ && \
    fc-cache -fv
WORKDIR /
RUN rm -rf /noto

#実行するコマンド
ENTRYPOINT ["node","/yoyaku/app/script/yoyaku.js"]

参考にした記事(ありがとうございます) qiita.com morizyun.github.io

yoyaku.js

const puppeteer = require('puppeteer');
const targetUrl = "http://hogehoge.com";

//スリープ用の関数定義
async function sleep(mSec) {
  return new Promise(resolve => setTimeout(resolve, mSec));
}

(async () => {
  const browser = await puppeteer.launch({
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox'
    ]
  });

  const page = await browser.newPage();
  //ページ移動
  await page.goto(targetUrl);


//以下よく使いそうな処理の例
//-------------------ここから-------------------

  //dialogが出てきたら承認する
  page.on('dialog', async dialog => {
    console.log(dialog.message());
    await dialog.accept();
  });


  //要素のクリック + 遷移がある場合は読み込みを待つ
  page.click('#セレクタ');
  await page.waitForNavigation({timeout: 60000, waitUntil: "domcontentloaded"});

  //文字入力(セレクトボックスの選択などもできる)
  await page.type('input[name="セレクタ"]', '入力したい文字');

  //要素のvalue取得
   var value = await page.$eval('#セレクタ', item =>{
      return item.textContent;
   })

  //ログ出力
  console.log("なんか識別用の文字", 変数名);

  //スリープ(上部で定義した関数呼び出し)
  await sleep(5000);

  //スクリーンショット(実行ディレクトリに保存される)
  await page.screenshot({ path: '画像名' });

  //処理を抜ける
  process.exit();

//-------------------ここまで-------------------

  browser.close();
})();

build

docker build -t yoyaku:latest .
最後の『.』はカレントディレクトリのDockerfileを意味する

run

docker run --rm -it -v `pwd`/app/script:/yoyaku/app/script -w /yoyaku/app/script yoyaku:latest
『-v』でローカルとコンテナ内をマウントする(コンテナ内で保存したスクリーンショットを残すため)
『-w』でワーキングディレクトリを指定する

備考

crontabで設定するときはdocker runコマンドの『-it』をとらないとエラーになる

スプレッドシートで複数の置換を一度に行うGoogle Apps Script

やりたいこと

スプレッドシート上で、1つの文字列に対して複数のルールに基づいた置換を行う
SUBSTITUTEの引数に置換ルールの連想配列を渡してくるくるさせる感じ

シート

単純に3枚つかってやってみる

辞書用(dict)

A B
1 key value
2 EVXS
3 vuPV
4 3SjD 既婚
5 D53v 未婚
6 3fhq 子あり
7 kqYu 子なし

入力用(input)

A B
1 name ->
2 keys ->

出力用(output)

A B
1 name values
2

GAS

function multiSubstitute() {
  var dictSheetName = "dict";
  var inputSheetName = "input";
  var outputSheetName = "output";

  //シート取得
  var dictSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(dictSheetName);
  var inputSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(inputSheetName);
  var outputSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(outputSheetName);
  
  //dict(辞書)シートから連想配列を作成
  var dictRange = dictSheet.getRange(2,1,7,2);// 2行目, 1列目(A), 7行目, 2列目(B) =A2:B7
  var dictValues = dictRange.getValues(); 
  var imDict = {};
  for each(var val in dictValues){
    imDict[val[0]] = val[1];
  }
  
  //inputシートの入力を取得
  var inputRange = inputSheet.getRange(1,2,2);// 1行目, 2列目(B), 2行目, 省略(列に変化がない) =B1:B2
  var inputValues = inputRange.getValues();
  var name = String(inputValues[0]);//名前
  var keysString = String(inputValues[1]);//キーが入った文字列 とりあえずStringにキャスト
  if(name == "" || keysString == ""){
    return;
  }
  //cookie用にurlencodeされた文字列をそのまま貼り付けてもいいようにデコード
  var keysString = keysString.match(/^%5B.*/) ? urldecode(keysString) : keysString;
  
  //辞書にあわせてkeysをvaluesに置換
  var valuesString = keys2values(keysString, imDict);
  
  //outputシートに書き出し
  outputSheet.appendRow([name,valuesString]);
  
  //inputシートの入力をクリア
  inputRange.clear();
  
  function urldecode(str){
    var decoded = decodeURI(str);
    //decodeURIだとカンマが置換されなかったので仕方なく置換
    //replaceが最初の要素しか置換しないのでカンマがなくなるまで繰り返す
    var result = decoded;
    do{
      decoded = result;
      result = decoded.replace("%2C", ",");
    }while(decoded !== result);
    return result;
  }

  //辞書の要素数だけ回してしらみつぶしに置換
  function keys2values(str, imDict){
    Object.keys(imDict).forEach(function(key){
      str = str.replace(String(key), String(this[key]));
    }, imDict);
    return str;
  }
}

結果

入力用(input)

A B
1 name -> test1
2 keys -> {"EVXS","D53v","kqYu"}

これで実行して、

出力用(output)

A B
1 name values
2 test1 {"男","未婚","子なし"}

こうなる
入出力を1シートでやる場合は、入力範囲を選択してscript実行⇒となりの列に出力とかにすればよさそう