クーポンを公開するための運用をボットによって改善した話

Merpay Advent Calendar 2020 の 14 日目は、メルペイのクーポンチームのフロントエンドエンジニアの @tokuda109 がお送りします。

今日はクーポンが新規公開されるまでに 18 営業日かかっていた運用を、ボットを導入することで 9 営業日まで改善した話を紹介します。 本記事では、クーポンを世に出すまでにどのような工程があるのかを紹介したあと、実際のボットの実装方法について解説していきます。

はじめに

メルペイのクーポンは、お店での決済に利用できます。決済金額の割引やポイントの還元が受けられるお得な機能です。

メルペイでは、クーポンを新規公開するまでに次のような工程で進めています。

  1. 配布するクーポンの内容(スケジュールやポイントなど)について決める
  2. 決まった内容を元にクーポン素材を作成する
  3. 作成した素材が実際の画面でどのように表示されるのかを加盟店様に確認して頂く
  4. フィードバックがあれば、工程2に戻り再修正を行う
  5. 加盟店様確認の完了後、QAチームによる最終チェックを行う
  6. 本番反映

この各工程においてさまざまな職種の方の作業が行われます。特に工程2と工程3は、手作業の部分が多く、工数がかかるため、改善する余地がありました。

我々は、工程2の作業を クーポン画像の作成 と呼び、工程3の作業を プレビュー画像の作成 と呼んでいます。

改善前の運用方法

運用改善前の クーポン画像の作成プレビュー画像の作成 で、どのような運用を行っていたかを説明します。

クーポン画像の作成 では、配布するクーポンの内容に沿って、必要な素材を作成します。

メルペイのクーポン画像には、クーポン一覧で表示する画像からレジでスキャンしてもらうコードの画像など、画面内の表示する箇所ごとに画像のバリエーションがあります。 それぞれの画像作成を行うために文字やレイアウトなどの細かいデザイン調整を毎回依頼していたため、デザイン工数がかさんでいました。

次に プレビュー画像の作成 では、クーポン画像の作成 によって作られた画像が、実際の画面上でどのように表示されるのかを加盟店様に確認して頂くための確認用の画像となります。

運用改善前は、プレビュー画像の作成 に実機でスクリーンショットを撮っていました。スクリーンショットを撮る以外にも確認に不要な部分を消したりする画像修正も必要で、全て手作業のため大きな手間がかかっていました。

ボットで改善

クーポンチームでは、今回の運用改善以前にも Slack 上でボットが人間の代わりに様々なタスクを行っていました。テスト環境へのデータ反映やJIRAのチケット管理を始めとして、運用の大部分を Slack 上から行うことができます。

ボットの名前は coupon-ta と言い、Hubot で実装されています。Hubot の実装に関する記事はたくさんあるため、ここでは割愛します。

処理はボットとタスクの2つに分けられ、ボットが Slack からコマンドを受け取り、処理の完了後に結果を返します。 そして、ボットから呼び出され、単一の処理を行うのがタスクになります。

クーポン画像の作成プレビュー画像の作成 はタスクに該当し、ボットから呼び出され処理を行います。 それぞれの実装方法については後述しますが、ボットからのタスク呼び出し部分は次のような実装になっています。Slack から受け取ったメッセージをパラメータに分解し、タスクを実行するコマンドを組み立てています。

import cp = require("child_process");

module.exports = (robot): void => {
  robot.respond(/create\s+image\s+(?<params>.*)?/i, (msg) => {
    const { params } = msg.match.groups;

    const productId = params.match(/(productId:(?<productId>[\w-]*))/).groups['productId'];
    const logoId = params.match(/(logoId:(?<logoId>[\w-]*))/).groups['logoId'];
    const title = params.match(/(title:\"(?<title>[\S\s][^:]*)\")/).groups['title'];
    const price = params.match(/(price:(?<price>[\d]+))/).groups['price'];

    let command = [
      `docker run --net=host --rm`,
      `-e PRODUCT_ID=${productId}`,
      `-e LOGO_ID=${logoId}`,
      `-e TITLE="${title}"`,
      `-e PRICE=${price}`
      `node index.js`
    ].join(' ');

    msg.reply("画像生成中...");

    cp.exec(command, (error, stdout) => {
      if (error) {} // エラー処理
      msg.reply(`画像のアップロードが終わりました ${stdout}`);
    });
  });
};

タスク

クーポン画像の作成

クーポン画像の作成方法は、作成する画像のレイアウトをマークアップで行い、ヘッドレスブラウザのコンテンツ部分に描画させ、それをスクリーンショットを撮る方法を採用しました。ヘッドレスブラウザには、Puppeteer を使用しています。Hubot と同様、使い方は割愛します。

ここではサンプルコードを用いて、簡単な実装の説明をします。

(async () => {
  const browser = await puppeteer.launch({
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
    headless: true,
    defaultViewport: { width: 750, height: 1334 }
  });

  const page = await browser.newPage();

  // ① Google Driveの商品画像とロゴ画像をデータURIとしてダウンロード
  const productId = process.env.PRODUCT_ID;
  productDataURI = await loadImage(productId);

  const logoId = process.env.LOGO_ID;
  const logoDataURI = await loadImage(logoId);

  // ② 環境変数としてタイトル&価格を文字列として受け取る
  const title = process.env.TITLE;
  const price = process.env.PRICE;

  // ③ 生成する画像のレイアウトをマークアップで記述する
  const html = `
<html>
<head>
<meta charset="utf-8" />
<style>
.wrapper {
  display: flex;
  width: 960px;
  height: 384px;
}

.product-img {
  width: 615px;
  height: 384px;
  object-fit: cover;
}

.meta {
  position: relative;
  padding: 36px 12px 24px;
  width: 345px;
  box-sizing: border-box;
  line-height: 1;
  text-align: center;
  color: #fff;
}

.logo-img {
  margin-bottom: 18px;
  height: 120px;
  border-radius: 4px;
  object-fit: cover;
}

.title {
  margin: 0;
  line-height: 45px;
  font-size: 30px;
}

.price {
  display: flex;
  margin: 24px 0 0;
  padding: 0 12px;
  justify-content: center;
  vertical-align: baseline;
}

.price-yen {
  margin-top: 12px;
  line-height: 80px;
  font-size: 60px;
}

.price-value {
  letter-spacing: -4px;
  line-height: 1;
  font-size: 90px;
}

.price-off {
  margin-top: 32px;
  letter-spacing: -6px;
  line-height: 1;
  font-size: 36px;
}
</style>
</head>

<body>
<div class="wrapper">
  <img class="product-img" src="${productDataURI}" />
  <div class="meta">
    <img class="logo-img" src="${logoDataURI}" />
    <p class="title">${title}</p>
    <p class="price">
      <span class="price-yen">¥</span>
      <span class="price-value">${price}</span>
      <span class="price-off">引き</span>
    </p>
  </div>
</div>
</body>
</html>`;

  // ④ 画像レイアウトのマークアップをコンテンツとして描画させる
  await page.setContent(html);

  // ⑤ ページ全体をスクリーンショットを撮って保存する
  await page.screenshot({
    type: 'jpeg',
    path: `${IMG_PATH}`,
    quality: 100,
    fullPage: true
  });

  // Google Driveなどに画像アップロードする処理
  // uploadImage({ ... });

  await browser.close();
})();

①と②でボットから指定された環境変数を受け取りデータとして保持します。 そのデータを③の部分でマークアップとして展開します。 ④の page インスタンスの setContent にマークアップの文字列を渡し、ヘッドレスブラウザ内で描画します。 最後に⑤でヘッドレスブラウザ内で描画された内容をスクリーンショットを撮って画像生成をします。

プレビュー画像の作成

プレビュー画像の作成 の基本的な実装は クーポン画像の作成 と同様で、Puppeteer を使ってマークアップをヘッドレスブラウザ内で描画させ、スクリーンショットを撮っています。

メルペイタブの画面はネイティブアプリの画面のため、あらかじめ用意した画像(下図の①)を背景にして、クーポンエリアにクーポン画像を上から重ねた上でスクリーンショットを撮ります。2枚の画像を重ね合わせるだけなのでマークアップとしては簡単です。

一方、クーポン詳細画面はWebViewの画面なので、アプリの側部分だけの画像(下図の②)をあらかじめ用意します。そして、Puppeteer でウェブページにアクセスしてスクリーンショットを撮り、コンテンツ部分にはめ込んだ上で再度スクリーンショットを撮ります。

① メルペイタブの背景画像 ② WebView画面の背景画像
メルペイタブの背景画像 ② WebView画面の背景画像

実際には、次のような実装になっています。

const imageToBase64 = require('image-to-base64');
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
    headless: true,
    defaultViewport: { width: 750, height: 1334 }
  });

  const page = await browser.newPage();

  // ① クーポン詳細画面に遷移し、コンテンツ部分のスクリーンショットを撮る
  await page.goto(`${PATH_TO_URL}`);

  await page.screenshot({
    type: 'jpeg',
    path: `${DETAIL_IMG_PATH}`,
    quality: 100,
    fullPage: true
  });

  // ② 背景画像とコンテンツ部分のスクリーンショットの画像を読み込む
  const bgImg = await imageToBase64('./detail_bg.png');
  const detailImg = await imageToBase64(`${DETAIL_IMG_PATH}`);

  // ③ プレビュー画像としてレイアウトするためのマークアップを用意する
  //    body要素にアプリ部分の画像を表示させ、その上からコンテンツ部分の
  //    スクリーンショットを撮ったものを重ねている
  const html = `
<html>
<head>
<style>
body {
  position: relative;
  margin: 0;
  width: 750px;
  height: 1334px;
  background: url(data:image/png;base64,${bgImg}) no-repeat #eee;
}

.detail-img {
  position: absolute;
  display: block;
  overflow: hidden;
  top: 129px;
  left: 0px;
  width: 750px;
}
</style>
</head>

<body>
  <img class="detail-img" src="data:image/png;base64,${detailImg}" />
</body>
</html>`;

  // ④ マークアップををコンテンツとして描画させる
  await page.setContent(html);

  // ⑤ ページ全体をスクリーンショットを撮って保存する
  await page.screenshot({
    type: 'jpeg',
    path: `${IMG_PATH}`,
    quality: 100,
    fullPage: true
  });

  // Google Driveなどに画像アップロードする処理
  // uploadImage({ ... });

  await browser.close();
})();

①と②でクーポン詳細画面に遷移し、コンテンツ部分のスクリーンショットを撮ります。 ③でアプリのWebViewの側部分のみの画像のコンテンツ部分と重ね合わせるためのマークアップを用意し、④で描画します。 ⑤で重ね合わせた状態で、画面全体を再度スクリーンショットを撮ります。

実際の使い方

最後に実際に実行したものを紹介します。

クーポン画像の作成 は次のように実行します。

coupon-taからクーポン画像の作成をする方法

Google Driveに次の画像が作成されました。

coupon-taから作成したクーポン画像

デザイン修正依頼をすることなく、クーポン画像を作成することができます。フォントサイズやカーニングなどのレイアウト調整もパラメータで指定できるようにしています。

次に プレビュー画像の作成 は次のように実行します。

coupon-taからプレビュー画像の作成をする方法

実機を使ってスクリーンショットを撮らなくても各画面のプレビュー画像が作成され、クーポン画像の作成 と同様にGoogle Drive上に保存されます。

最後に

今日はクーポンチームのボットを使った運用改善について紹介しました。

今年の7月からクーポン数が今までより増えることが分かっていたため、6月から実装を開始しました。運用工数が半分になるのはすごく効果があり、無事乗り越えることができました。

今日紹介した クーポン画像の作成プレビュー画像の作成 の運用作業は、ほぼ毎日のように行われます。毎日使われるものを実装することができたのはエンジニア冥利に尽きます。

メルペイではミッション・バリューに共感できるソフトウェアエンジニアを募集しています。一緒に働ける仲間をお待ちしております。

明日の Merpay Advent Calendar 2020 執筆担当は、Backend Engineer の yoshiki.shibata さんです。引き続きお楽しみ頂ければ幸いです。