🍟

2023年に作成した社内業務支援システムをまとめてみた

2023/12/07に公開

今年の IVRy Advent Calendar は紅白対抗戦を行っています!

https://x.com/IVRy_jp/status/1730521537294520802?s=20

https://adventar.org/calendars/9247

https://adventar.org/calendars/9453


株式会社 IVRy (アイブリー) 社員番号 7 番 エンジニアのボルドーです。
私は普段 IVRy ではフロントエンドとモバイルアプリの開発を担当しています。

今回は私が 2023年に作成した社内業務支援システムをまとめて紹介します。

2023年に作成した業務支援システム

一部合宿で開発したものを含みますが、基本的には業務時間外に私が有志として取り組んだものです。
時系列に沿って紹介していきます。

時期 システム 利用頻度 補足
1,2月 Slack Reaction 集計 月1,2回 月初めの表彰時に利用の他、任意のリアクションが気になった際に利用
3月 コラム記事の Textlint 週1回以上 毎週公開しているコラム記事の Pull Request 提出時に利用
5月 VoIP Push 通知の自動着信テスト 毎日 毎日昼夜問わず稼働中
6月 メンタルヘルスチェック 週1回 アンケート送信が週1回、回答受付は常時稼働中
9月 特定の Slack チャンネル群に一括参加 不明 Workspace 内の全ユーザーに公開しているため頻度は不明
9月 動きがない Slack チャンネルの自動アーカイブ 月1回 月末に予告、月初にアーカイブ実行
10月 OCR 名刺自動書き起こし 不定期 展示会開催期間中は数分単位で実行

Slack Reaction 集計

昨年のアドベントカレンダーにて、弊社の定めている Value を根付かせる目的で 3つの Value それぞれの Slack カスタム絵文字 を作成し、誰が体現できているか集計して競い合える仕組みを作りました。

https://zenn.dev/ivry/articles/60a97aae9166f0

その後 1,2月に改良を加えまして、リアクションを 貰った / あげた人それぞれを集計できるようにすると共に、スプレッドシートに書き出すようにしました。
というのも、紹介した ユーザー毎の reactions.list を取得して集計する処理では全てのリアクションが取得できているため実行時に引数に指定していないものも難なく集計できる利点があるので活用しました。


コマンドで指定したリアクションの中で左から多い順、続いて指定されていないものが多い順に並びます[1]

結果として 1年経った今でも様々な用途で集計結果を楽しんでもらえていると自負しています。

スプレッドシートに書き出す際にシステムの絵文字とカスタム絵文字それぞれを表示する部分に結構苦戦しました。
システム絵文字は対応マップを地道に作成し、カスタム絵文字は emoji.list で取得した 画像URL をスプレッドシートの image関数 を用いて表示するようにしています。
その他 行と列の固定 や一覧性を考慮した幅調整などを API で行うこともやや苦労しました。

コラム記事の Textlint

3月にコラム記事の運用改善について紹介する記事を書きました。

https://zenn.dev/ivry/articles/e4a13bd03b91ae

こちらはすぐに需要がなくなるかと思いきや最近行った大量のリライトで大活躍しており Textlint の仕組みがあって助かりました。


先日大量の lint error が出た PR 上のキャプチャ

3月に運用が辛いけど

入稿ツールの開発は優先順位が上がらず実現できていません。

と言っていたのは決して嘘ではないのですが、開発メンバーも増えて今では一部ですが CMS に移行が完了してプレビューも可能な状態となっています。
本当にあっという間の 1年でした。近々開発を担当したメンバーのテックブログとして紹介できるかと思います。

VoIP Push 通知の自動着信テスト

5月に着信通知に関する障害を発生させてしまったことがきっかけで VoIP Push 通知の着信を定期的に確認する仕組みを作りました。[2]

https://zenn.dev/ivry/articles/882a969e0f3bb6

今も毎日 社内に常設してある実機が通知を受けており、先日 人員増加に伴うネットワーク増強対応で社内ネットワークが数時間停止していた際には無事に(?)アラートを上げていました。
このシステムのログがせっせと Slack に吐かれていることによってお客様からの「通知が来ない」という問い合わせを受けても全体影響ではないと確信を持って心穏やかにいられるので助かっています。

メンタルヘルスチェック

社員のメンタル面のアラートを機械的に検知する仕組みとして毎週 現在の調子を5択で問う Slack BOT を代表 奥西の要望を受けて開発し、試験運用しています。[3]


毎週 5択の質問が DM で届いて回答している様子

このシステムは 6月に開催した 第1回IVRy開発合宿 にて作成しました。実質開発時間は 4,5時間とタイトだったためハマらないように事前調査等行った上で臨み無事時間内に仕上げることができました。

https://zenn.dev/ivry/articles/0937b755bb2a86

この時初めて Slack の Block Kit Builder を使ってみたのですが表示を整える体験の良さに驚きました。[4]

特定の Slack チャンネル群に一括参加

弊社が掲げている Vision は We make “Work is Fun” from now 〜「働くことは、楽しい」を常識に変えていく〜 でして、夏頃に 楽しく働くために全員で話し合う場 が数回設けられました。その際に各自の times チャンネルを有効利用するとともに、新しく入ってきた人が既存の times チャンネルに気軽に入って交流できると良いという話が出たので Slack 次世代プラットフォームを使って作成してみました。

1. 起動コマンド


slash command で実行する際のキャプチャ

2. フォーム起動


条件を指定するフォームが起動している様子

3. 検索結果の表示と一括参加


条件に応じて検索結果を表示し、1件以上参加可能なチャンネルがあれば Join ボタンが表示される様子

詳しくは以下の記事で紹介しています。

https://zenn.dev/ivry/articles/59a29bcfc16b8a

先日も プログラマのフルリモートワークにダジャレが向いている理由とその功罪 にて以下のような話があり興味深く拝見しました。

業務だけの関係では見せられない側面を開示する機会を作ることが色々と模索されている時代だと言えるでしょう。

動きがない Slack チャンネルの自動アーカイブ

弊社は Slack でのコミュニケーションが活発なので Slack チャンネルが日夜増え続けています。
放っておくととんでもない数になってしまうため、100日間動きがないチャンネルはアーカイブしてほしいという要望を受けて開発しました。

Slack 次世代プラットフォームの紹介記事で触りだけですが紹介しています。

https://zenn.dev/ivry/articles/59a29bcfc16b8a#1.-初級-%26-無料(スタンダードワークフローなので制限なし)

最後の実行ログを見ると現在アクティブなパプリックチャンネル数は 475 あるようです。紹介記事内の 9月時点で 418チャンネルあり 21チャンネルアーカイブしていたことから 3ヶ月で 78チャンネル増えた計算になります。


月初に実行されている様子

OCR 名刺自動書き起こし

代表 奥西との 1on1 にて社内業務の困りごとを相談・集約するチャンネルがあると良さそうという話になり その場で #corporate-bpr [5]というチャンネルを作成しました。
チャンネルの意図を共有した際に即座に困りごとをあげてくれたのが マーケターの泉山 でした。
内容としては

  • 不定期で参加している展示会にて頂戴した名刺をスキャナで取り込み Google Drive にアップロードしている
  • これを可能な限り早く文字起こししてスプレッドシートにまとめたい
  • こういった SaaS はあるがやりたいことは極めてシンプルなので簡単にできるなら社内で完結したい

というものでした。
その時点で GPT-4V はまだ発表されておらず、Google Drive の OCR が使えそうであると Qiita のリンク付きで共有されたので、そのままだと楽しくないのでせっかくならと google/clasp (Command Line Apps Script Projects) を使って開発してみました。

サンプル画像


Google Drive に用意したサンプル画像

サンプル画像を処理している様子


未処理のファイルがあればテキスト化 & スプレッドシートが更新されて二度目以降はスキップ

ざっくり実装紹介

clasp をインストール後、 clasp login clasp create を行います。
package.json と appsscript.json はこんな感じになりました。

package.json
package.json
{
  ...
  "scripts": {
    "lint": "eslint 'src/**/*.ts' && prettier --check '**/*.{js,ts,json}'",
    "lint:fix": "eslint --fix 'src/**/*.ts' && prettier --write '**/*.{js,ts,json}'",
    "test": "jest --watch",
    "push": "yarn lint && clasp push"
  },
  "devDependencies": {
    "@types/google-apps-script": "1.0.70",
    "@types/jest": "29.5.5",
    "@types/node": "20.6.3",
    "@typescript-eslint/eslint-plugin": "6.7.2",
    "@typescript-eslint/parser": "6.7.2",
    "eslint": "8.49.0",
    "eslint-config-prettier": "9.0.0",
    "eslint-plugin-import": "2.28.1",
    "jest": "29.7.0",
    "prettier": "3.0.3",
    "ts-jest": "29.1.1",
    "ts-loader": "9.4.4",
    "ts-node": "10.9.1",
    "typescript": "5.2.2"
  }
}
src/appsscript.json

drive からファイルを読み込んで documents でテキスト化した結果を spreadsheets で書き込むので以下のようになりました。

src/appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Drive",
        "serviceId": "drive",
        "version": "v2"
      },
      {
        "userSymbol": "Document",
        "serviceId": "docs",
        "version": "v1"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/spreadsheets"
  ]
}

やっていることはこれだけです。

  1. Drive の指定されたフォルダ内の対象ファイル(PDF と 画像)を探す
    • この時 既にテキスト化済みのものは除外する
  2. 対象ファイルの場合一時的に Document として保存して ORC でテキストを取得する
  3. 結果を指定された Spreadsheet に書き込む
  4. 再度処理しないように対象ファイルの名前にテキスト化済み prefix を付与する[6]
src/main.ts
src/main.ts
// OCR 結果を書き込むシート名はいずれの場合でもここで設定されている文字列
const SHEET_NAME = "OCR";

// OCR 済みのファイル名にはこの prefix をつける
// これがついているファイルは処理されない
const OCR_PREFIX = "[OCR]";

// フォルダIDとスプレッドシートIDを 1ペアとして複数指定可能
const FOLDER_ID_AND_SPREADSHEET_ID_MAP = {
  // eslint-disable-next-line @typescript-eslint/naming-convention
  "folder-id-1": "spreadsheet-id-1",
  "folder-id-2": "spreadsheet-id-2",
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const main = () => {
  for (const [folderId, spSheetId] of Object.entries(FOLDER_ID_AND_SPREADSHEET_ID_MAP)) {
    const sheet = getSheetBySpreadSheetIdAndName(spSheetId, SHEET_NAME);
    const folder = getFolderById(folderId);
    const files = folder.getFiles();

    console.log(`フォルダ(${folderId})内の全ファイルを走査します`);
    let count = 0;
    while (files.hasNext()) {
      count++;
      const file = files.next();
      const mimeType = file.getMimeType();
      console.log(`\t└ ${count.toString().padStart(3, " ")}. ファイル ${file.getName()} (mimeType: ${mimeType})を処理します`);
      // pdf と画像以外はスキップ & OCR済みのファイルはスキップ
      if (!(mimeType === "application/pdf" || mimeType.startsWith("image")) || file.getName().startsWith(OCR_PREFIX)) {
        console.log(`\t\t└ OCR対象ではないのでスキップします`);
        continue;
      }
      console.log(`\t\t└ OCRにかけます`);
      const ocrText = getOcrText(file);
      const fileName = OCR_PREFIX + file.getName();
      const hyperLink = `=HYPERLINK("${file.getUrl()}", "${fileName}")`;
      const readableDate = Utilities.formatDate(new Date(), "JST", "yyyy/MM/dd HH:mm:ss");
      console.log(`\t\t└ OCR結果を書き込みます`);
      sheet.appendRow([hyperLink, readableDate, ocrText]);
      // 最後に PREFIX をつけて OCR済みのファイルにリネームする
      file.setName(fileName);
      console.log(`\t\t└ 完了したのでファイル名を ${fileName} に変更しました`);
    }
    console.log(`フォルダ(${folderId})内の全ファイル(${count})を走査しました`);
  }
};

// 画像またはPDFファイルをOCRにかけてテキスト化したものを返す
const getOcrText = (targetFile: GoogleAppsScript.Drive.File) => {
  const mediaData = targetFile.getBlob();

  //Driveに一時的に作成するドキュメント情報
  const resource = {
    title: "tmpDocForOCR",
    mimeType: targetFile.getMimeType(),
  };

  // OCR機能をオン & 言語は日本語
  // ref: https://developers.google.com/drive/api/reference/rest/v2/files/insert#query-parameters
  const optionalArgs = {
    ocr: true,
    ocrLanguage: "ja",
  };

  const docFile = Drive.Files?.insert(resource, mediaData, optionalArgs);
  const docFileID = docFile?.id;
  if (!docFile || !docFileID) return "error";

  const doc = DocumentApp.openById(docFileID);
  const ocrText = doc.getBody().getText();

  //作成したドキュメントを削除
  Drive.Files?.remove(docFileID);

  return ocrText;
};

const getFolderById = (folderId: string) => {
  const folder = DriveApp.getFolderById(folderId);
  if (!folder) {
    throw new Error(`指定されたフォルダ(${folderId})が存在しません`);
  }
  return folder;
};

const getSheetBySpreadSheetIdAndName = (spreadSheetId: string, sheetName: string) => {
  const spreadSheet = SpreadsheetApp.openById(spreadSheetId);
  if (!spreadSheet) {
    throw new Error(`指定されたスプレッドシート(${spreadSheetId})が存在しません`);
  }
  const sheet = spreadSheet.getSheetByName(sheetName);
  if (!sheet) {
    const newSheet = spreadSheet.insertSheet(sheetName);
    newSheet.appendRow(["ファイル名", "OCR実行日時", "OCR結果テキスト"]);
    return newSheet;
  }
  return sheet;
};

この Apps Script は初回実行時に 実行者の Auth を取りに来るので担当者を Apps Script の管理者に追加して実行 & トリガーを設定してもらえば任意の実行間隔で自由に文字起こしできます。PDF と 画像どちらも対応しているので様々な用途に対応できると思います。

ひと通り実装が完了して Azure OpenAI と連携しようかなというタイミングで べいえりあさんに Bard 使ったらどう?と助言いただいて真顔になったのは内緒です。無知って怖いですね。[7]

https://ai-workstyle.com/bard-imagerecognition/

とはいえ、普段業務で関わりの少ない方と気軽に要件等話しながら進められて楽しかったです。

まとめと来年の抱負

業務には単純だけど時間を要する類のものが多々あります。私の自戒を込めた思いとして、DX を掲げるスタートアップにいる以上、単調な仕事をこなすことに慣れず一人一人が業務の中で効率化できる要素がないか日々考えなくてはいけないと思っています。
そうしたきっかけを少しでも与えられていたら良いなと思って今年も1年社内業務を手助けできるものを作ったり、 #corporate-bpr チャンネルを作ったりしていました。

先日開催された第2回開発合宿では #corporate-bpr チャンネルで上がってきた困りごとを 弊社1人目エンジニアの 小瀬 がサクッと実装・解決しておりとても頼もしかったです。

こうして振り返ってみると経験や実力の不足で休日を丸々費やしたことなどを思い出して悔しさが込み上げてきました。来年はもっと AI を活用してハマりどころを減らせるよう精進したいです。

以上、来年もがんばります!

We are hiring!!

最後に、IVRy では一緒に働く仲間を絶賛募集中です。
今の所 順調に成長してきていますが、今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!

脚注
  1. そのため他の上位のリアクションが目立って value のリアクションが少なく見えますが、毎日数件ずつは付いており根付いてきています。 ↩︎

  2. 私が業務時間外で作ったこのような簡素なものだけでなく、Twilio のアラートや別のモニタリングシステムも元々存在しており、見落としてしまった原因等ポストモーテム会議は社内で実施しました ↩︎

  3. 社員のプライバシー保護のため情報の取り扱いには十分注意しています ↩︎

  4. 一点ハマりどころがあり悩んだ記憶だけあるのですが忘れてしまいました... ↩︎

  5. BPR: Business Process Re-engineering ↩︎

  6. ファイルを削除もしくは移動でも良かったのですが、使用者が混乱しないように改名としました。 ↩︎

  7. 実装時期の数週間前には発表されていたようです ↩︎

IVRyテックブログ

Discussion