📑

履歴書の画像をOpenAI APIで解析して情報を抽出してみた

に公開

始めに

今までOpenAI APIを触ったことのなかったので
他の記事で、OpenAI API(GPT-4o)を使って画像等を認識、解析してデータ化した記事があったので
そちらを参考に今回自身の記録として残すことにしました。
私の場合TypeScript、Expressを組み合わせて、履歴書画像をアップロードし、その内容をJSON形式で構造化・表示させてみました。
認識ミスありましたらご教示いただけますと幸いです。

行ったこと

  • ブラウザから履歴書画像(PNG/JPEG等)をアップロード
  • Base64形式に変換してOpenAI APIに送信
  • ChatGPTが内容を解析し、JSON形式で構造化されたデータとして返す
  • そのJSONをHTML上で表として表示する

使用技術

フロントエンド:HTML, JavaScript(FileReader, fetch)
バックエンド:Node.js, TypeScript, Express
外部API:OpenAI GPT-4o

1. クライアント側:画像をアップロードしてfetchで送信

HTML+JSの構成で以下のように実装しました。
JavaScript側でやっている処理の要点は次の通りです:

  • FileReader を使って画像ファイルをBase64形式に変換
  • fetch でその文字列をサーバーへPOST(Content-Type: application/json)
  • サーバーの返却したJSON(構造化データ)をJSONオブジェクトに変換して画面に表示
  • 単純な項目(氏名やE-mail)と複数行(学歴・職歴)をループで回してテーブル表示
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>履歴書表示</title>
</head>
<body>
  <h1>履歴書情報</h1>
  <input type="file" id="imageInput" accept="image/*">
  <button id="upload">解析する</button>
  <pre id="output" style="display: none;"></pre>
  <div id="table-container"></div>

  <script>
    document.getElementById("upload").addEventListener("click", async () => {
      const fileInput = document.getElementById("imageInput");
      const file = fileInput.files[0]; 
      console.log("file:",file); // 画像ファイルの情報を表示
      console.log("fileInput.files[0]", fileInput.files[0])

      if (!file) {
        alert("画像ファイルを選択してください");
        return;
      }

      const reader = new FileReader();
      reader.onload = async () => {
        const base64 = reader.result.split(",")[1];
        const response = await fetch("/read-card", {
          method: "POST",
          headers: {
            "Content-Type": "application/json"
          },
          body: JSON.stringify({ base64Image: base64 })
        });

        const data = await response.json();
        console.log("data:",data);
        console.log(typeof data);
        
        // JSONも表示
        document.getElementById("output").style.display = "block";
        document.getElementById("output").textContent = JSON.stringify(data, null, 2);

        // 表形式に描画
        const container = document.getElementById("table-container");
        container.innerHTML = "";

        const simpleFields = ["氏名", "生年月日", "性別", "現住所", "電話番号", "E-mail"];
        const table = document.createElement("table");
        table.border = 1;

        simpleFields.forEach(field => {
          const tr = document.createElement("tr");
          const th = document.createElement("th");
          th.textContent = field;
          const td = document.createElement("td");
          td.textContent = data[field] || "";
          tr.appendChild(th);
          tr.appendChild(td);
          table.appendChild(tr);
        });

        ["学歴", "職歴"].forEach(field => {
          const tr = document.createElement("tr");
          const th = document.createElement("th");
          th.textContent = field;
          const td = document.createElement("td");
          const ul = document.createElement("ul");

          (data[field] || []).forEach(item => {
            const li = document.createElement("li");
            li.textContent = item;
            ul.appendChild(li);
          });

          td.appendChild(ul);
          tr.appendChild(th);
          tr.appendChild(td);
          table.appendChild(tr);
        });

        container.appendChild(table);
      };

      reader.readAsDataURL(file); 
    });
  </script>
</body>
</html>

2. ExpressサーバーとOpenAI連携

import express from "express";
import { config } from "dotenv";
import { OpenAI } from "openai";

config(); // Load environment variables from .env file

const app = express();
app.use(express.static("public"));
app.use(express.json({ limit: "10mb" })); // Parse JSON bodies

const apiKey = process.env.OPENAI_API_KEY;

const openai = new OpenAI({
 apiKey: apiKey,
});

app.post("/read-card", async (req: any, res: any) => {
 try {
   const base64Image = req.body.base64Image;
   if (!base64Image) {
     return res.status(400).json({ error: "No image file provided." });
   }
   const response = await openai.chat.completions.create({
     model: "gpt-4o",
     messages: [
       {
         role: "developer",
         content:
           "You are an Optical Character Recognition (OCR) machine. You will extract all the characters from the image file provided by the user, and you will only provide the extracted text in your response. The response should be output in JSON format.",
       },
       {
         role: "user",
         content: [
           {
             type: "text",
             text: "この履歴書の画像から以下の情報をJSON形式で構造化して読み取ってください。「氏名」「生年月日」「性別」「現住所」「電話番号」「E-mail」「学歴(配列形式)」「職歴(配列形式)」。それぞれの項目が複数ある場合は、学歴・職歴は配列にしてください。学歴と職歴は、たとえ1件しかなくても必ず配列形式(JSON配列)で返してください。画像が履歴書でない場合は 履歴書のデータではないという内容のErrorを返してください。",
           },
           {
             type: "image_url",
             image_url: {
               url: `data:image/png;base64,${base64Image}`,
             },
           },
         ],
       },
     ],
     response_format: { type: "json_object" },
     temperature: 1,
   });
   console.log("response :", response);
   console.log(JSON.stringify(response, null, 2));
  
   const result = JSON.parse(response.choices[0].message.content!);
   res.json(result);
 } catch (error) {
   res.status(500).json({
     error: "An error occurred while processing the image.",
     details: error,
   });
 }
});

app.listen(3000, () => {
 console.log("http://localhost:3000 でサーバーが起動しました");
});

コードの解説

必要なライブラリを読み込みます。プロジェクトに.envファイルを作成し
OpenAiのシークレットキーを環境変数として扱います。

import express from "express";
import { config } from "dotenv"; // .env ファイルから環境変数を読み込む
import { OpenAI } from "openai"; // OpenAI API を使うためのライブラリ

// .env ファイルの内容を process.env に読み込む
config();

今回htmlファイルをpublicフォルダの中に入れたので静的ファイルの場所を指定します。
画像をuploadしたときに容量が多すぎてエラーになったので大きめの画像をuploadしてもサーバーにリクエストできるようにします。

// public フォルダの中身を静的ファイルとして提供
app.use(express.static("public"));

// JSON形式のリクエストボディ(画像データなど)をパースして req.body に入れてくれる
app.use(express.json({ limit: "10mb" })); // 大きめの画像に備えて10MBまで許可

OpenAI API へリクエストを送ります。
詳細はこちらの公式をみるといいかもです。 私も少しだけ参照しました。(日本語化しても難しい。。。)
公式: https://platform.openai.com/docs/guides/text?api-mode=responses

ちなみにresponse_formatでスキーマ形式で返して欲しいならtype: "json_schema"とすればいいみたいですが対応するモデルは gpt-4o-mini gpt-4o-2024-08-06以降らしいです。
公式: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#structured-outputs-vs-json-mode

role: "developer"のcontentですが、精度を高めるために基本的には英語が良いみたいです。
画像についてですが、ローカル環境からの画像を扱う場合画像形式はbase64である必要があるようです。
画像形式はjepg,webp.png形式であり 画像は最大10MBらしいです。私は今回ギリギリですね。
公式: https://platform.openai.com/docs/guides/fine-tuning#other-considerations-for-vision-fine-tuning

    const response = await openai.chat.completions.create({
      model: "gpt-4o", // 使用するモデル
      messages: [
        {
          role: "developer", // 開発者からのルール指示
          content:
            "You are an Optical Character Recognition (OCR) machine. You will extract all the characters from the image file provided by the user, and you will only provide the extracted text in your response. The response should be output in JSON format.",
        },
        {
          role: "user", // 実際の指示(プロンプト)
          content: [
            {
              type: "text",
              text:
                "この履歴書の画像から以下の情報をJSON形式で構造化して読み取ってください。「氏名」「生年月日」「性別」「現住所」「電話番号」「E-mail」「学歴(配列形式)」「職歴(配列形式)」。それぞれの項目が複数ある場合は、学歴・職歴は配列にしてください。学歴と職歴は、たとえ1件しかなくても必ず配列形式(JSON配列)で返してください。画像が履歴書でない場合は 履歴書のデータではないという内容のErrorを返してください。",
            },
            {
              type: "image_url", // 画像を渡す形式
              image_url: {
                // base64データをURLとして指定
                url: `data:image/png;base64,${base64Image}`,
              },
            },
          ],
        },
      ],
      response_format: { type: "json_object" }, // JSONオブジェクトとして返してもらう
      temperature: 1, // 応答の創造性(今回は多少柔軟に読み取ってもらう)
    });

また、APIで帰ってくるresponseの型はObjectですが、
取得したい情報はresponse.choices[0].message.contentの中に格納されています。しかし、response_format: { type: "json_object" } を指定しても
response.choices[0].message.contentの型はobjectではなくstringになります。

response → object
response.choices[0].message → object
response.choices[0].message.content → string

console.log(typeof response.choices[0].message.content! === "string"); // true

そのためサーバー側でresponse.choices[0].message.contentをJSON.parse()してobjectにしてJavaScriptで使いやすくしてから、
クライアントにres.json()で返す(HTTPで送るために再びJSON文字列化)しています。

ちなみに定数response(Objectの型になっている)をJSON.stringifyを使ってJSON文字列にした場合出力は下記のような構造になっています。
contentの値の型はstringになっているのが確認できます。

{
 "id": "chatcmpl-BSZfw4juJgHL9MOKdQToDdNuCgrn7",
 "object": "chat.completion",
 "created": 1746150020,
 "model": "gpt-4o-2024-08-06",
 "choices": [
   {
     "index": 0,
     "message": {
       "role": "assistant",
       "content": "{\n    \"氏名\": \"転職 一郎\",\n    \"生年月日\": \"平成1221日\",\n    \"性別\": \"男\",\n    .............. //文字列になっている
       "refusal": null,
       "annotations": []
     },
     "logprobs": null,
     "finish_reason": "stop"
   }
 ],
 "usage": {
   "prompt_tokens": 1301,
   "completion_tokens": 336,
   "total_tokens": 1637,
   "prompt_tokens_details": {
     "cached_tokens": 0,
     "audio_tokens": 0
   },
   "completion_tokens_details": {
     "reasoning_tokens": 0,
     "audio_tokens": 0,
     "accepted_prediction_tokens": 0,
     "rejected_prediction_tokens": 0
   }
 },
 "service_tier": "default",
 "system_fingerprint": "fp_55d88aaf2f"
}

そしてサーバーからクライアントにJSONを返しJavascirptでObjectに変換し、ループで回します。

まとめ

画像をアップロード→OpenAIで画像を解析→JSON構造化→UI表示までを1つの流れで作ってみました。
なんとか時代に取り残されないように勉強頑張らないとな。。。と思いました。
頑張ります。

参考記事:
https://qiita.com/youtoy/items/3844c6904b6a39fdad64
https://qiita.com/PlanetMeron/items/2905e2d0aa7fe46a36d4

株式会社ソニックムーブ

Discussion