< Back

毎日画像をOCRしてスマホに通知するサービスを作ったので簡単なアーキテクチャを紹介

Mermaid

簡単な解説

RaspberryPi as ラズベリーパイ

定期実行はすべてラズベリーパイを使ってます。メルカリで買ったやつ。

公開サーバーにするための記事も含めて、このあたりで色々書いてます。

以下のように ssh pi でログインして使ってます。

$ ssh pi

$ ~/.ssh/config
Host pi
  HostName raspberrypi.local
  User __yourname__
  IdentityFile /Users/__yourname__/.ssh/id_rsa_pi
  RemoteCommand cd /home/__yourname__/workspace/piapp; $SHELL -il
  RequestTTY yes

VSCodeもRemote SSHのプラグインがあれば > Remote-ssh: Connect to Host でpiを選択するとそのまま開発できます。ローカルよりも動作がもっさりなので私はMacOS側で開発して GitHub経由の git push git pull を使って開発してました。

NodeJS as Node.js(NodeCron)

雰囲気

$ brew install n
$ n auto
$ npm i

PlanetScale as PlanetScaleDB

言わずとしれたオンラインDBで、2021年11月に総額1億500万ドル(約150億円)を調達しているみたい。

参考

ラズベリーパイを公開サーバーにしていないので、ラズベリーパイにsshしないとデータが見えないよりも、オンラインサービスとして自分だけにでも公開したりするためにオンラインDBを利用してます。

PCからのアクセスは Sequel Aceを使ってます。

接続情報を入力したら簡単にアクセスできてクエリも叩けるのでおすすめ。

スクリーンショット 2023-12-25 15.08.47.png

PlanetScaleでRead権限のみのユーザーを発行して普段はそれを使えますね。
書き込むときだけWrite権限ユーザー使しましょう。(私は面倒なので基本Write権限ユーザーでクエリ叩いてます。。。。。)

PlayWriteNodeJS as PlayWrite

ここで雰囲気のコードを公開しています。

画像のスクリーンショットも取れるので必要があれば使えます。
もちろん生DOMを解析してそれだけを返せるならそれが一番軽いです。

zipのダウンロードはこれくらいの記述でいけます。
ロケーターが表示されるまで待機するコードとか必要ないのでかなり楽にコードが書けます。

await page.goto('URL');
await page.locator('input[name=password]').fill('password'));
await page.locator('input[type=submit]').click({ timeout: 120 * 1000 })
await page.locator("a[title$='.zip']").click({ timeout: 120 * 1000 });
const download = await downloadPromise;
console.log('file name: ', download.suggestedFilename());
await download.saveAs(`${downloadsPath}/${download.suggestedFilename()}`);

ラズベリーパイが非力で表示するのに30秒以上かかるケースが多いのでタイムアウトを2分にしてます。 { timeout: 120 * 1000 }

TesseractJS as Tesseract.js

OCRはこのあたりを参考に書きました。

512MBのラズパイでもそれなりに動くので一旦満足してます。

こんな感じで動きます。ファイルへのpathがあれば動きます。

const worker = await createWorker('jpn');

for (const p of paths) {
    console.log('Fired OCR: ', p);
    const { data: { text } } = await worker.recognize(p);
    values.push(`### ${today}\n\n${text}`);
}

ただ結果は空白入りとかで返ってくるのでどうにかして整形したい。

こんな感じで動きます。ファイルへのpathがあれば動きます。
↓
こ ん な感 じで  動 きます。ファ イ ル  へのp ath があ  れ ば 動きま す。

たぶんTextLintとかでいけると思ってますが、イマイチ設定がわからないので一旦あきらめてます。

QiitaAPI as QiitaAPI

Privateのブログを作成し、QiitaのAPIを使って取得するたびに追記をしていってます。

次のNtfySHの通知が本文5kbくらいの制限があり attachment.txt みたいな形式で送られてきてしまうので、本文そのままの添付を諦めて一旦QiitaのPrivateブログに追記してます。

こんな感じで取得と編集をしています。

const res = await fetch(`https://qiita.com/api/v2/items/${id}`, PUBLIC_OPTIONS);
const res = await fetch(`https://qiita.com/api/v2/items/${id}`, {
    ...AUTH_OPTIONS,
    method: 'PATCH',
    body: JSON.stringify({
        body: bodyString,
        title,
    })
});

編集時に body だけが必須かと思ってずっと失敗してましたが title も必須でした。ドキュメントのしたの方に書かないでほしかった。。。。:bow:

NtfySH as ntfy.sh

自分用LineDeveloperアカウントや自分用Slackで通知してた方は一度試してみてほしいです。

15.1Kのスターを集めているOSSで、自社でもホスティングできます。

投稿回数の制限等はあるものの https://ntfy.sh/ でも十分通知可能(1日250回ほど通知できる)なので一旦無料プランで試してみてください。

これだけで通知ができるのは簡単すぎませんか?

fetch('https://ntfy.sh/mytopic', {
    method: 'POST', // PUT works too
    body: 'Backup successful 😀'
})

Smartphone as スマホ

NtfySHのアプリが必要ですが簡単に通知が飛んできます。

https://ntfy.sh/ をChromeで入り、サイトの通知を許可するとアプリなしでもchrome経由の通知が可能のはず

最後に

今回自分用のサービスを公開してアーキテクチャ図?を作ってみました。
正直mermaidが使いたかっただけですが、誰かの参考になればと思い供養投稿になります。

できなかったこと

MacOS上で PlanetScale + Prisma を使い開発していて、いざバッチ処理開始するときにPrismaをラズベリーパイにいれられないことが判明した。以下記事が参考になったが、そもそも私のメルカリで買った安めの非力ラズベリーパイだとビルド時間かかりすぎて終わりそうになかったので一旦あきらめてmysql2でアクセスしてます。SQL直書きしてます。

mysql2

こういうサービスのconnectionを切るのってどうすればいいのかわからない。
繋ぎっぱなしでいいの?切ったら切ったらで繋げないし、コネクトが二個あるときはエラーで落ちるし、ってことで await connection.connect().catch(_ => console.log('Already connected')); バッチ処理最終にコネクトを切って、毎度コネクトをキャッチして使うことにした。あっているかわからない。

export const runQuery = async <T = any>(query: string, options: any[]): Promise<T[]> => {
  if (!connection) {
    if (!process.env.DATABASE_URL) {
      const msg = 'DATABASE_URL is not defined';
      console.error(msg);
      throw new Error(msg);
    }
    connection = await createConnection(process.env.DATABASE_URL);
  }
  await connection.connect().catch(_ => console.log('Already connected'));
  const [rows] = await connection.query(query, options)
  return rows as T[];
}