LoginSignup
13
4

More than 5 years have passed since last update.

音声認識で家計簿をもっと便利にする話

Last updated at Posted at 2018-12-19

Advent Calendar 17日目です。遅刻してしまいすいませんでした:bow:
本記事は個人的な見解であり、所属する組織の見解を代表するものではありません。

今回は音声認識を使って家計簿を付ける話をします:smile:

TL;DR

  • 操作が多くて家計簿付けるのは面倒なので音声認識でやりたい
  • 自分が使っている家計簿アプリのmoneyforwardには一般に公開されているAPIがない
  • Web Speech APIで音声認識してヘッドレスブラウザのPuppeteerで家計簿アプリを操作
  • moneyforwardの公式APIが待ち遠しい

はじめに

皆さん、家計簿してますでしょうか。
私はmoneyforwardという家計簿アプリを使って日々の収支を管理しています。
銀行口座との連携もされていて給与やボーナスが入ったら簡単に確認できるのでメチャクチャ便利です。
(もちろん他にも便利な機能はたくさんありますよ!)
このアプリ日々改善されていて重宝させていただいているのですが、
ただ、自分が極度のめんどくさがり屋なのもあって毎日ちゃんと家計簿を記入できていません。
具体的にどこが面倒なのかというと一つのアイテムを登録するのに踏むステップ数が多いところだと思っています。

アイテムを追加する際は、ホーム画面には「カンタン入力」というセクションがあるので、そこに以下の内容を記入(あるいは選択)します。

  • 大分類
  • 小分類
  • 日付を変更 (今日以外の日であれば)
  • 金額
  • 内容 (任意): 分類だけだと後で見た際に具体的に何に使ったかが分からないので、私は記入することが多いです。

これだけの内容をアイテムごとに登録しないといけないので、どうしても後回しになってしまいます。(必須なのが3つしかないのにもかかわらず面倒くさがっちゃうんです...)
そうするとレシートがドンドンたまりますし、レシートが無いものについては忘れてしまい、
結果的に正しく家計簿を付けられなくなってしまいます。

この問題を解決するため重要だと考えたのが、どれだけ入力にかかる負担を軽減するかです。

解決方法

キーボードやタッチパネルによる入力に時間がかかってしまうのであれば、音声入力で操作できればよいのでは?と考えました。
最近はalexaやGoogle Homeなどのスマートスピーカーが普及してきいて、音声で何かを操作することがどれだけ便利なのかを感じますね(自分は使ったこと無いんですが:bomb:)。
本当はスマートスピーカーを使ったほうが実用的だとは思うのですが、プロトタイプとして簡単なウェブアプリを作ることにしました。

今回考えたシステムの構成図は次のようになっています。ソースコード
kakeibo.jpg

まず全体の流れですが、
1. クライアントウェブアプリ (Reactで作成)を起動し、マイクに向かって登録したいアイテムの内容を喋ります。
2. ウェブアプリはブラウザのWebSpeechAPIを使って音声認識をします。
3. 認識結果をパーサーに投げて構文解析をします。ここはGolangで書いています。
4. 構文解析の結果を自前のMoneyforward APIに投げてアイテムを登録します。そうなんです、自前なんです。というのもMoneyforwardには公開APIがないのです。APIのページはあるのですが、「現在は、クローズドAPIとして、特定のパートナー企業様へ提供をしております。」とあり、一般公開はされていません。ならばブラウザを操作してアイテムの登録とかができる擬似的なAPIを作ろうということになったんです。
パーサーと自前Moneyforward APIはgRPCで通信します。この2つをくっつけちゃっても良かったとは思うんですが、gRPCを使って異なる言語で通信がしてみたかったという理由だけで別サービスにしました。

ここからは各コンポーネントについて重点ポイントに絞って説明していきたいと思います。

Web Speech APIによる音声認識

Reactでこんな画面を作りました。
「Kakeibo」というボタンを押すと録音を始めて発話が途切れると録音が終わるので、パーサーに音声認識の結果を送信します。
image.png

技術的には、ボタンを押すと音声認識結果に対するRxjs Observableが作成されるので、Reactのコンポーネントはそれをsubscribeします。
音声認識が終わるとコンポーネントに認識結果が流れるので、axiosでパーサーにPOSTします。
ちなみにObservableの作成は以下のように行っています。
まだexperimentalな機能のためchromeだとベンダープレフィックスがついた形での提供となっているため、環境の違いを吸収するためにSpeechRecognitionという変数を定義しています。

あと個人的に分かりにくいなと思ったのが、音声認識の結果が出るとonresultという関数が呼ばれます。
実際の結果はevent.resultsに入っているのですが、形は配列の配列になっています。
これはどういうことかというと、continuoustrueにっている場合は音声認識が終わっても続けて次の音声を聞き続けるのですが、音声認識の結果に過去の認識結果も含まれるため配列となります。
中の配列に関してですが、maxAlternativeという音声認識の結果の候補を最大いくつ返すかという設定があり(デフォルトは1)、これが2以上に設定されている場合は配列の長さも1より大きくなることがあります。
continuousfalsemaxAlternative1だからといってスカラーにしてくれるとかはないです。

speech_recognition.js
import {Observable} from 'rxjs';

const SpeechRecognition =
  window.SpeechRecognition || window.webkitSpeechRecognition;

export const createSpeechObservable = () => {
  return Observable.create(observer => {
    const recognition = new SpeechRecognition();
    recognition.continuous = false;
    recognition.interimResults = false;
    recognition.lang = 'ja-JP';
    recognition.onresult = event => {
      return observer.next(event.results[0][0]);
    };
    recognition.onerror = error => {
      observer.error(error);
    };
    recognition.start();
  });
};

パーサーとAPIサービス間のproto

パーサーとAPIサーバー間の通信用のメッセージタイプをprotocol bufferで以下のように定義します。
今回gRPCで呼べるものはアイテムを追加するAddItemだけです。
Itemはいつ支払いをしたか(payedAt)、カテゴリ(category)、支払額(price)、メモ(memo)からなります。

syntax = "proto3";

service Kakeibo {
  rpc AddItem(Item) returns (Response) {}
}

message Item {
  PayedAt payedAt  = 1;
  Category category = 2;
  int32 price = 3;
  string memo = 4;
}

message PayedAt {
  int32 month = 1;
  int32 date = 2;
}

message Category {
  string big = 1;
  string small = 2;
}

message Response {
  bool ok = 1;
}

パーサー

パーサーはGolangで書かれたHTTPサーバーです。
上記のprotoファイルをGo用にコンパイルします。そうするとprotoファイルと同じディレクトリにservice.pb.goというファイルができます。

protoc -I=. --go_out=plugins=grpc:. ./service.proto

次にHTTPサーバーを用意します。HTTPサーバーは/へのPOSTのリクエストのボディをパースし、
正しくパースできればgRPCでAPIサーバーにリクエストを投げます。

context.WithTimeout(context.Background(), 5 * time.Second)にあるとおり、
gRPC通信する際のタイムアウトをかなり長めの5秒としているのですがこれは
APIサーバーがブラウザでの操作に時間を要するためそれに合わせた形になります。

実際のパージングはfunc convert(text string) (*service.Item, error)でやっています。
正規表現にマッチングさせてアイテムのインスタンスを返しているだけです。protoファイルでは支払日やメモのフィールドも作ったのですが、実装の都合上今回は省かせていただきました :bow:
日本語の正規表現をやったことがなかったんですが、以下でひらがな、カタカナ、漢字にマッチさせることができるようです。

  • \p{Hiragana}: ひらがな
  • \p{Katakana}: カタカナ
  • \p{Han}: 漢字
main.go
package main

import (
    "context"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "regexp"
    "strconv"
    "time"

    "github.com/hitochan777/kakeibo/backend/converter/service"
    "google.golang.org/grpc"
)

func convert(text string) (*service.Item, error) {
    // Do some conversion
    re := regexp.MustCompile(`([\p{Hiragana}|\p{Katakana}|\p{Han}]+)の([\p{Hiragana}|\p{Katakana}|\p{Han}]+)が(\d+)円`)
    match := re.FindStringSubmatch(text)
    if match == nil {
        return nil, fmt.Errorf("Failed to parse raw text")
    }
    category := &service.Category{
        Big:   match[1],
        Small: match[2],
    }
    price, err := strconv.Atoi(match[3])
    if err != nil {
        return nil, err
    }
    return &service.Item{Category: category, Price: int32(price)}, nil
}

func main() {
    // gRPC client setup
    conn, err := grpc.Dial("localhost:11111", grpc.WithInsecure())
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()
    client := service.NewKakeiboClient(conn)

    // Router setup
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != "POST" {
            w.WriteHeader(http.StatusMethodNotAllowed)
            return
        }
        body, err := ioutil.ReadAll(r.Body)
        defer r.Body.Close()
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        rawText := string(body)
        log.Println("Received: ", rawText)
        item, err := convert(rawText)
        log.Println(item)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
        defer cancel()
        _, err = client.AddItem(ctx, item)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
        }
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

puppeteerでmoneyforwardのAPI作成

次にpuppeteerを用いたAPIの作成です。
puppeteerは、ChromeやChromiumを操作するためのハイレベルAPIを提供するNode.jsのライブラリです。

protoファイルのコンパイルについてですが、Golangとは違って動的にコードを生成する方法をとっているので、事前のコンパイルは不要です。
事前にコンパイルをしたものを使うこともできるようです。

getServer()でgRPCサーバーの作成をしています。AddItemというgRPCサービスをどの関数と結びつけるか(今回だとaddItem)という設定をしているぐらいです。

addItemはリクエストを受け付けたら、puppeteerで以下を行います。

  1. moneyforwardのログインページを開いてログイン。ユーザー名とパスワードとは環境変数から引っ張ってくる。
  2. 大項目と小項目、金額を入力して提出。

ちょっと躓いたのが大項目と小項目の入力です。カテゴリはaタグで表現されており、aタグの中身がリクエストの内容と一致するものを探してきてクリックするという動作が必要だったのですが、提供されているメソッドの多くがセレクタしか受け付けないのでどうしたものか。。。となりました。
調べていると、XPathと呼ばれるフレキシブルにXMLのパーツをアドレッシングするための記法がpuppeteerでも使えるらしく、この記事を参考に引数で与えられた文字列を含む要素をクリックするメソッドclickByTextを追加しました。

main.js
const puppeteer = require('puppeteer');
const grpc = require('grpc');
const protoLoader = require('@grpc/proto-loader');

const PROTO_PATH = __dirname + '/../service.proto';
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});

const kakeibo = grpc.loadPackageDefinition(packageDefinition).Kakeibo;

const input = {
  id: process.env.MF_ID,
  pass: process.env.MF_PASS,
};

const escapeXpathString = str => {
  const splitedQuotes = str.replace(/'/g, `', "'", '`);
  return `concat('${splitedQuotes}', '')`;
};

const clickByText = async (page, text) => {
  const escapedText = escapeXpathString(text);
  const linkHandlers = await page.$x(`//a[contains(text(), ${escapedText})]`);

  if (linkHandlers.length > 0) {
    await linkHandlers[0].click();
  } else {
    throw new Error(`Link not found: ${text}`);
  }
};

const addItem = async ({request}) => {
  console.log(request);
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://moneyforward.com/users/sign_in');

  await page.type('#sign_in_session_service_email', input.id);
  await page.type('#sign_in_session_service_password', input.pass);
  await page.click('#login-btn-sumit');
  await page.waitForNavigation()

  await page.click("#js-large-category-selected")
  await clickByText(page, request.category.big)
  await page.click("#js-middle-category-selected")
  await clickByText(page, request.category.small)
  await page.type('#js-cf-manual-payment-entry-amount', `${request.price}`);
  await page.click("#js-cf-manual-payment-entry-submit-button")

  await browser.close();
};

const getServer = () => {
  const server = new grpc.Server();
  server.addService(kakeibo.service, {
    AddItem: addItem,
  });
  return server;
};

const main = async () => {
  const server = getServer();
  server.bind('0.0.0.0:11111', grpc.ServerCredentials.createInsecure());
  server.start();
};

main();

おわりに

今回はWeb Speech APIで音声認識をした結果からmoneyforwardをpuppeteerで操作することで家計簿をカンタンに操作できるアプリ(のプロトタイプ)を紹介しました。
もちろん、APIが整えばpuppeteerを使う必要もなくなり今回のアプリの実現はより簡単になります。公式APIの公開が待ち遠しいです:bow:
また、パーサーの部分がルールベースでの解析になってしまっているのですが、ここを自然言語処理を用いてより汎用的なものにできれば
結構使い物になるのではないかと思いました!

13
4
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4