🥝

スーパーのチラシにキウイが載っていたら自動でLINEに通知するようにした

2021/09/13に公開

よく行く近所のスーパーにて、いつもはキウイが1個100円で売られているのですが、たまに、1個60円になる時があります。いわゆる、セールというやつです。

このスーパーにはチラシが存在して、キウイが安くなる時には、どうやらチラシに載っているようでした。また、スーパーの公式サイトでチラシ画像が公開されていることが分かったので、

毎朝、チラシ画像を自動的に取得・解析して、キウイがチラシに載っていたらLINEに通知する

システムを作ることにしました。これで安くなったキウイを逃しません。

システム概要

システムは、GCP(Google Cloud Platform)にて構築してみました。大まかな流れは、以下の図の通りです。

メインの処理は、Cloud Functionsを使って、

  1. チラシが掲載されているサイトに対してスクレイピングを行い、チラシ画像を取得する
  2. 取得したチラシ画像をCloud Vision APIに送り、テキスト抽出を行う
  3. 抽出されたテキストに、キウイが含まれている場合に、LINE Messaging APIを実行する

という流れで処理を行います。また、Cloud Schedulerを使うことで、毎朝同じ時間に実行するようにしました。

(LINE通知を行うためには、LINE Developersに登録して、公式アカウントを作ったりする必要がありますが、この辺りの詳細については割愛します。)

メイン処理の詳細

Cloud Functionsに登録する内容は、以下の通りです。

まず、package.jsonには、使用するパッケージを記載しておきます。GoogleもLINEも、各API用のSDKがあるので、とても簡単に開発を進められます。

package.json
{
  "name": "kiwi",
  "version": "0.0.1",
  "dependencies": {
    "@google-cloud/vision": "^2.4.0",
    "@line/bot-sdk": "^7.4.0",
    "jsdom": "^17.0.0",
    "node-fetch": "^2.6.1",
    "require": "^2.4.20"
  }
}

メインの処理は、以下の通りです。もし試す場合は、スーパーのURLなどを適宜変更してください。また、エントリポイントは、kiwiNotifyという関数になります。

main.js
const fetch = require("node-fetch");
const vision = require("@google-cloud/vision");
const line = require("@line/bot-sdk");
const fs = require("fs");
const jsdom = require("jsdom");

const searchText = "キウイ"; // この単語にヒットしたらLINE通知
const siteBaseUrl = "https://hogehoge-super.com"; // スーパーのサイトURLのベース部分
const fetchOption = {
  method: "GET",
  encoding: null,
  headers: {
    // サイトにアクセスできない可能性があるので、一応指定
    "User-Agent":
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36",
  },
};
const visionApiClient = new vision.ImageAnnotatorClient();

// メイン処理
exports.kiwiNotify = async (req, res) => {
  // スクレイピングして、チラシ画像のURL(複数)を取得する
  const flyerPaths = await getFlyerPaths();
  
  // チラシ画像を解析して、テキスト情報を取得する
  const results = await Promise.all(
    flyerPaths.map((path) => {
      const flyerUrl = `${siteBaseUrl}${path}`;
      return getTexts(flyerUrl);
    })
  );

  // キウイが存在すれば、チラシ画像ごとにLINEに通知する
  results.forEach((result) => {
    if (result.texts.includes(searchText)) {
      notifyLine(result.flyerUrl);
    }
  });

  if (res) res.status(200).send("Done.");
};

const getFlyerPaths = async () => {
  const pageResponse = await fetch(
    `${siteBaseUrl}/hogehoge.html`, // チラシ画像のあるページ
    fetchOption
  );
  const body = await pageResponse.text();
  const dom = new jsdom.JSDOM(body);
  const document = dom.window.document;
  const nodes = document.querySelectorAll(
    ".hogehoge a" // 頑張ってスクレイピングしてください
  );
  const flyerPaths = Array.from(nodes).map((node) => node.href);
  if (flyerPaths.length === 0) {
    throw new Error("No flyrers found.");
  }

  return flyerPaths;
};

const getTexts = async (flyerUrl) => {
  const res = await fetch(flyerUrl, fetchOption);
  const data = await res.buffer();
  // ファイル名にスラッシュがあるとダメなので、適当にハイフンなどに置換する
  const fileName = `/tmp/${flyerUrl.replace(/\//g, "-")}`;
  fs.writeFileSync(fileName, data, "binary");
  const [result] = await visionApiClient.textDetection(fileName);
  const texts = result.textAnnotations.map((d) => d.description);
  return {
    texts: texts,
    flyerUrl: flyerUrl,
  };
};

const notifyLine = (flyerUrl) => {
  const lineClient = new line.Client({
    channelAccessToken: "hogehoge", // アクセストークン
  });
  const message = {
    type: "text",
    text: `${searchText}が安い可能性があります。\n${flyerUrl}`,
  };
  lineClient.broadcast(message);
};

100行以内で済んでしまいました。とてもお手軽かと思います。

まとめ

これで、キウイが安くなったタイミングを逃さない、と思っていたのですが、チラシ画像からテキストが正しく抽出されない時があることに気づきました。やはり、画像からのテキスト抽出には限界があるようです。

例えば

  • シャインマスカット => シインマスカット(「ャ」が無い)
  • ナガノパープル => ナガDープル(「ノパ」が「D」になる)

などのように、数文字がおかしくなるケースがありました。

チラシ画像の商品名は、文字が枠などの装飾で強調されていることが多いので、特に難しいのかもしれません。間違って抽出された場合の"当たり判定"を、どこまでチューニングしていくかが、実は一番大切なのかもしれません。

Discussion