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


## Mermaid

```mermaid
sequenceDiagram
    participant RaspberryPi as ラズベリーパイ
    participant NodeJS as Node.js(NodeCron)
    participant PlanetScaleDB as PlanetScaleDB
    participant PlayWriteNodeJS as PlayWrite
    participant TesseractJS as Tesseract.js
    participant QiitaAPI as QiitaAPI
    participant NtfySH as ntfy.sh
    participant Smartphone as スマホ

    RaspberryPi ->> NodeJS: Foreverでデーモン化
    NodeJS ->> PlanetScaleDB: 各種データはオンライン保存
    PlanetScaleDB -->> NodeJS: DB確認して処理済みなら再実行
    NodeJS ->> PlayWriteNodeJS: 画像を取得
    PlayWriteNodeJS -->> NodeJS: 画像をダウンロード
    NodeJS ->> TesseractJS: 画像をOCR解析
    TesseractJS -->> NodeJS: 解析結果
    NodeJS ->> QiitaAPI: ブログに投稿
    QiitaAPI -->> NodeJS: URLを取得
    NodeJS ->> NtfySH: スマホにブログURLを通知
    NtfySH ->> Smartphone: 通知
```

## 簡単な解説

### RaspberryPi as ラズベリーパイ

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

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

https://qiita.com/ykhirao/items/f26e18364594e7077612


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

```terminal
$ 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)

雰囲気

```terminal
$ brew install n
$ n auto
$ npm i
```

### PlanetScale as PlanetScaleDB

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

参考

https://qiita.com/tak001/items/cfbaa9dcb542929ff235

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

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

https://sequel-ace.com/

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

![スクリーンショット 2023-12-25 15.08.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112929/d197591e-b8bb-a19f-36c4-fe24f5db8d76.png)

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

### PlayWriteNodeJS as PlayWrite

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

https://qiita.com/ykhirao/items/9b564e65d9c0a19bf2e0

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

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

```ts
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はこのあたりを参考に書きました。

https://qiita.com/yamayamasan/items/1dd911b817c8bb51fc43

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

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

```ts
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}`);
}
```

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

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

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

### QiitaAPI as QiitaAPI

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

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


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

```js
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://github.com/binwiederhier/ntfy

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

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

```js
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直書きしてます。

https://zenn.dev/bowz/articles/03c551269218aa

### mysql2

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

```ts
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[];
}
```