MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第11回】CSVで既存の台帳を一括取り込みする

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第11回です。前回(第10回)では、現在地からの半径や地図上に描いたエリアで地点を絞り込めるようにしました。今回は、Excel などで管理している既存の台帳をCSVで一括取り込みする画面を作ります。緯度・経度がある行はそのまま登録し、住所しかない行はサーバーで1件ずつ住所を緯度・経度に変換(ジオコーディング)してから登録します。

    「店舗や物件の一覧はもうExcelにある。1件ずつ地図で登録し直すのは現実的じゃないけど、CSVで入れるとお金はかかる?」

    結論から言うと、緯度・経度が入っている行は Google の費用なしで取り込めます。費用が発生するのは住所しかない行で、1行につき Geocoding API を1回呼ぶ従量課金になります。そこで今回の画面は、取り込む前に必ずプレビューを出して「何行が住所変換になるか(=何回APIを呼ぶか)」を表示し、確認してから実行する流れにしました。住所の表記ゆれで変換できない行も出るので、その手直しの手間も見込んでおくことが大切です。

    目次

    この回で作るもの:CSVの一括取り込み

    手順画面の動き
    1. CSVファイルを選ぶサーバーが全行を検証し、プレビュー(登録できる/住所から変換して登録/重複のためスキップ/エラー)を表示。この時点では登録も住所変換もしない
    2. 件数と注意書きを確認する「住所から変換する行が4件あります。取り込むと Geocoding API を4回呼び出します」と表示
    3. 「◯件を取り込む」を押す住所だけの行を1件ずつ変換し、まとめて登録。行ごとの結果(登録しました/エラーの理由)を表示
    同じCSVをもう一度選ぶ登録済みの行は「重複のためスキップ」になる

    サンプルとして、10行のCSV(緯度経度あり5行・住所のみ4行・カテゴリが不正な1行)を samples/points-sample.csv に用意しました。

    名称,カテゴリ,住所,緯度,経度,メモ
    CSV取込 神田店,店舗,,35.6918,139.7709,緯度経度あり
    CSV取込 秋葉原店,store,,35.6984,139.7731,英語のカテゴリ名も可
    CSV取込 丸の内オフィス,物件,東京都千代田区丸の内1-9-1,,,住所のみ
    CSV取込 誤りのある行,倉庫,,35.68,139.76,カテゴリが不正
    (…ほか6行)

    仕組み:プレビューと本番を同じAPIで行う

    設計理由
    CSVの解析と検証はサーバーで行う(papaparse)画面側の検証は迂回できるため。第3回と同じく最終チェックはサーバーの zod
    dryRun: true(プレビュー)と false(取り込み)を同じAPIで処理「プレビューでは通ったのに本番で違う判定になる」ずれを防ぐ
    住所変換の前に、入力の誤りと重複を判定誤った行や登録済みの行で課金が発生しないようにする
    住所変換は1秒5件まで・1回100件までGoogle 側の上限や想定外の大量課金を避け、1回の処理時間を抑える
    登録はトランザクション(=全部成功するか、全部取り消すか)で一括途中で失敗して半端に登録された状態を残さない

    実装

    ファイル役割
    shared/types/import.ts取り込み結果の型(新規)
    server/utils/csv.tsCSVの解析と、見出し・カテゴリ名の読み替え(新規)
    server/api/points/import.post.tsプレビューと取り込みのAPI(新規)
    app/pages/import.vue取り込み画面(新規)
    app/layouts/default.vueヘッダーに「地図」「CSV取り込み」のリンク(更新)
    samples/points-sample.csv動作確認用のサンプル(新規)

    CSVの解析には papaparse(5系)を使います。

    npm install papaparse
    npm install -D @types/papaparse

    手順1:CSVを解析する

    server/utils/csv.ts(要点)

    import Papa from 'papaparse'
    
    // CSV の見出し → 項目名。日本語の見出しでも英語の見出しでも受け付ける
    const HEADER_ALIASES: Record<string, string> = {
      名称: 'name', name: 'name',
      カテゴリ: 'category', category: 'category',
      住所: 'address', address: 'address',
      緯度: 'lat', lat: 'lat',
      経度: 'lng', lng: 'lng',
      メモ: 'memo', memo: 'memo',
    }
    
    export function parsePointsCsv(text: string): { rows: CsvRow[]; errors: string[] } {
      const result = Papa.parse<Record<string, string>>(text.replace(/^/, ''), {
        header: true,
        skipEmptyLines: 'greedy', // 空白だけの行も飛ばす
        transformHeader: (h) => HEADER_ALIASES[h.trim()] ?? h.trim(),
        transform: (v) => v.trim(),
      })
      // …(「名称」「カテゴリ」と、「住所」または「緯度」「経度」の列があるかを確認し、
      //     行番号付きの CsvRow に詰め替える。省略)
    }
    
    // 「店舗」「store」のどちらでもカテゴリとして受け付ける
    export function toCategory(value: string): Category | null {
      if ((CATEGORIES as readonly string[]).includes(value)) return value as Category
      return CATEGORIES.find((c) => CATEGORY_META[c].label === value) ?? null
    }

    header: true で1行目を見出しとして扱い、各行を「見出し名 → 値」の形で受け取ります。transformHeader で日本語の見出しを内部の項目名に読み替えているので、現場の台帳の見出しをそのまま使えます。先頭の  は、Excel が UTF-8 の CSV の先頭に付けることがある目印(BOM)で、これが残ると1列目の見出しが「名称」と一致しなくなるため取り除いています。

    📰 出典:Papa Parse「Documentation」

    手順2:行ごとに検証し、住所だけの行を変換する

    server/api/points/import.post.ts(行ごとの処理の要点)

    const MAX_ROWS = 2000 // 1回に取り込める行数
    const MAX_GEOCODING_ROWS = 100 // 1回に住所変換する行数(= Geocoding API の呼び出し回数)の上限
    const GEOCODING_INTERVAL_MS = 200 // 住所変換の間隔(1秒に5件まで)。Google の上限より十分低くする
    
      for (const row of rows) {
        // 1) 入力の検証(住所変換より先に行い、誤った行で課金が発生しないようにする)
        //    …(カテゴリの読み替え、pointInputSchema による検証。省略)
    
        // 2) 重複(登録済み、または CSV の中で2回目以降)はスキップ
        const key = `${parsed.data.name}\t${parsed.data.category}`
        if (seen.has(key)) {
          result.status = 'duplicate'
          result.message = '同じ名称・カテゴリの地点が登録済み、またはCSV内で重複しています'
          continue
        }
        seen.add(key)
    
        if (hasLatLng) {
          result.status = 'ready'
          toInsert.push({ result, input: parsed.data })
          continue
        }
    
        // 3) 住所だけの行:プレビューでは件数を数えるだけ。取り込み時に住所変換する
        //    …(サーバー用キー未設定・上限の100件超えはエラーにする。省略)
        result.status = 'needs-geocoding'
        if (dryRun) continue
    
        if (geocodingStopped) {
          result.status = 'error'
          result.message = '前の行でAPIエラーが起きたため、住所変換を中断しました'
          continue
        }
        if (geocodingRows > 1) await sleep(GEOCODING_INTERVAL_MS)
        try {
          const [first] = await geocodeAddress(row.address, googleMapsServerKey)
          if (!first) {
            result.status = 'error'
            result.message = '住所から位置が見つかりませんでした'
            continue
          }
          result.granularity = first.granularity
          toInsert.push({ result, input: { ...parsed.data, lat: first.lat, lng: first.lng, placeId: first.placeId } })
        } catch (e) {
          // …(Google のエラー内容はサーバーのログにだけ出す)
          result.status = 'error'
          result.message = '住所の変換に失敗しました'
          // 400(キーが無効など)・401/403(権限)・429(上限超過)は次の行でも失敗するので止める
          if (status && [400, 401, 403, 429].includes(status)) geocodingStopped = true
        }
      }

    住所変換には、第6回で作った geocodeAddress()(サーバー用キーで Geocoding API v4 を呼ぶ関数)をそのまま使っています。ブラウザからは自社のAPIを呼ぶだけなので、サーバー用キーが画面側に出ることはありません。

    処理の順番がポイントです。検証 → 重複判定 → 住所変換の順にして、誤りのある行や登録済みの行で課金が発生しないようにしています。また、キーの誤りや利用上限の超過のように「次の行でも必ず失敗する」エラーが返ったら、残りの住所変換を止めます。筆者の環境でダミーのキーを使って試したところ、1行目で Google から「API key not valid」が返り、残り3行は呼び出さずに中断されました。

    重複の判定は「名称とカテゴリが同じなら同じ地点」という簡易的なルールです。実際の台帳では、管理番号の列で判定する、住所の表記を正規化して比べる、上書き更新にする、など要件によって正解が変わります。

    手順3:まとめて登録する

      // 4) まとめて登録する。途中で失敗したら全部取り消す(トランザクション)
      if (!dryRun && toInsert.length > 0) {
        const now = new Date().toISOString()
        await db.exec('BEGIN')
        try {
          for (const { input } of toInsert) {
            const p = pointInputSchema.parse(input)
            await db.sql`INSERT INTO points
                (name, category, lat, lng, memo, place_id, source, fetched_at, updated_at)
              VALUES (${p.name}, ${p.category}, ${p.lat}, ${p.lng}, ${p.memo},
                ${p.placeId}, ${p.source}, ${fetchedAtFor(p.source, now)}, ${now})`
          }
          await db.exec('COMMIT')
        } catch (e) {
          await db.exec('ROLLBACK')
          throw e
        }
        for (const { result } of toInsert) result.status = 'imported'
      }

    緯度・経度が入っていた行は source: 'csv'(自社の台帳の座標)、住所から変換した行は source: 'geocoding' として、place_id と取得日時(fetched_at)も一緒に保存します。この区別は第6回で説明した利用規約への備えです。Geocoding API で得た緯度・経度は、規約上の一時保存の期間(連続30日まで)の制限を受ける可能性があり、取得日時がないと後から判定できません。 台帳の座標は自社データなので、この制限の対象外として扱っています(最終的な判断は法務確認事項です)。

    📰 出典:Google Maps Platform「Service Specific Terms」

    手順4:取り込み画面

    app/pages/import.vue(スクリプトの要点)

    // Excel で保存した CSV は Shift_JIS のことが多い。まず UTF-8 として読み、失敗したら Shift_JIS で読む
    async function decodeCsv(file: File): Promise<string> {
      const buf = await file.arrayBuffer()
      try {
        return new TextDecoder('utf-8', { fatal: true }).decode(buf)
      } catch {
        return new TextDecoder('shift_jis').decode(buf)
      }
    }
    
    async function onFileChange(e: Event) {
      const file = (e.target as HTMLInputElement).files?.[0]
      // …(前回の結果を消し、1MB を超えるファイルは断る)
      csvText.value = await decodeCsv(file)
      preview.value = await send(true) // まずプレビュー
    }
    
    async function onImport() {
      result.value = await send(false) // 確認後に取り込み
      preview.value = null
    }

    日本の業務でよくつまずくのが文字コードです。Excel で「CSV」として保存すると Shift_JIS になることが多く、UTF-8 のつもりで読むと文字化けします。TextDecoder の fatal: true は「UTF-8 として正しくないバイト列ならエラーにする」指定で、エラーになったら Shift_JIS で読み直します。この判定は Node.js 24 で Shift_JIS に変換したサンプルを使って確かめました。

    📰 出典:MDN「TextDecoder」

    テンプレートでは、判定ごとの件数、住所変換の回数の注意書き、「◯件を取り込む」ボタン、行ごとの結果の表を出しています。エラー行は背景を赤くして、どの行を直せばよいかがすぐ分かるようにしました。

    動作確認の方法

    1. .env にサーバー用キー(NUXT_GOOGLE_MAPS_SERVER_KEY)を設定して npm run dev → ヘッダーの「CSV取り込み」を開く
    2. samples/points-sample.csv を選ぶ → プレビューで「登録できます 5件・住所から変換して登録 4件・エラー 1件」と、APIを4回呼ぶ旨の注意書きが出ることを確認
    3. 「9件を取り込む」→ 9件登録・1件エラー(カテゴリ「倉庫」は使えません)になることを確認。地図に戻って新しいピンを確認
    4. 同じCSVをもう一度選ぶ → 9件が「重複のためスキップ」になることを確認
    5. Excel で開いて Shift_JIS の CSV として保存し直したファイルでも、文字化けせずに取り込めることを確認

    筆者の環境で確認できたのは次のとおりです(ビルド後のサーバーに curl でCSVを送信)。

    条件結果
    サーバー用キーなし・プレビュー登録できます5件、住所だけの4件は「キー未設定のため変換できません」、カテゴリ不正1件
    ダミーのキー・プレビュー登録できます5件、住所から変換して登録4件、エラー1件
    ダミーのキー・取り込み5件登録。住所の行は1件目で Google から「API key not valid」が返り、残り3件は中断。登録した地点は source: 'csv'
    同じCSVを再度プレビュー5件が重複としてスキップ
    見出しが違うCSV400(必要な列の案内)
    緯度が「abc」の行「緯度の値が正しくありません」

    有効なキーでの住所変換の結果(どの住所がどの精度で変換されるか)と、ブラウザでの画面操作・Shift_JIS ファイルの選択は、執筆環境に本物のAPIキーとブラウザでの操作環境がないため確認できていません。 ご自身のキーを設定して確認してください。

    つまずきやすい点・セキュリティ上の注意

    • いきなり本番のデータで全件実行する:まず10行程度で試し、変換の精度(第6回の granularity。「おおよその位置」など)を確認してから本番の件数に進みます
    • 住所の表記ゆれ:「丁目・番地」の書き方、全角・半角、ビル名の有無で変換結果が変わったり、見つからなかったりします。失敗行を直して再取り込みする運用を決めておきます
    • 大量の件数を1回の通信で処理する:今回は1回100件・1秒5件に制限しています。数千件を変換するなら、画面の待ち時間では済まないので、裏側で順番に処理する仕組み(ジョブキュー)と進捗表示が必要です
    • トランザクションの簡略化:今回は1つのデータベース接続を共有する簡易的な書き方です。同時に何人も取り込む運用なら、データベース側の機能に合わせた作りにします
    • CSVの中身をそのまま信用する:名称やメモは画面に表示されるので、第4回と同じく HTML として埋め込まず、文字列として表示します(Vue の {{ }} は自動でエスケープされます)

    発注者向けメモ

    • 一括の住所変換は「件数×単価」の一時費用が発生します。実行前に、住所だけの行が何件あるかを数えて費用を試算し、Google Cloud の予算アラート(第12回で詳しく扱います)を設定してから実行してください。単価は改定されることがあるので、公式の料金ページで確認します
    • 台帳に緯度・経度の列があれば、住所変換の費用はかかりません。既存システムや測量データに座標がある場合は、住所ではなく座標で渡すと費用も規約上の制約も減らせます
    • 失敗行の手直しは人の作業です。数千件の台帳なら、数%でも数十〜数百件の確認が必要になります。この作業を誰が担当するか(発注側か開発会社か)を見積もりの段階で決めておきましょう
    • 重複の判定ルールは業務の要件です。「同じ名前の店舗が別の場所にある」「同じ場所に複数の点検項目がある」など、自社の台帳の実態に合わせて決める必要があります
    • 住所から求めた座標は、規約上の保存期間の制限を受ける可能性があります。台帳の座標(自社データ)と区別して保存されるかを確認してください

    発注者がやることのチェックリストです。

    • ☐ 取り込む台帳の件数と、住所だけの行の件数を数えた
    • ☐ 住所変換の費用を公式の料金ページで試算し、予算アラートを設定した
    • ☐ 重複とみなす条件(管理番号・名称・住所など)を決めた
    • ☐ 失敗行を誰がどう直すかを決めた
    • ☐ 台帳の文字コード(Shift_JIS か UTF-8 か)と列の並びを開発会社に共有した

    開発会社への確認に使える質問例です。

    • 「取り込みの前に、住所変換の回数(=費用)を確認できる画面はありますか?」
    • 「途中でエラーになったとき、どこまで登録されて、どこから直せばよいか分かりますか?」
    • 「住所から求めた座標と、台帳の座標は区別して保存されますか?」

    まとめと次回予告

    この回では、CSVの台帳をプレビューしてから一括登録する画面を作りました。検証と重複判定を住所変換より先に行って無駄な課金を防ぐこと、住所変換の回数を事前に見せること、変換の間隔と件数に上限を設けること、座標の出どころを保存して規約に備えること、の4点が押さえどころです。

    次回は最終回「本番公開前チェック:APIキー制限・クォータ・予算アラート・性能」です。ここまで作ったアプリを社内に公開する前に、キーの制限、APIごとの利用上限、予算アラート、性能、本番データベースへの切り替えをまとめて点検し、連載を締めくくります。

    この連載の記事一覧

    この記事は連載「NuxtとGoogle Mapsで作る情報集約マップ」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次