Node.js ジオコーディングAPIチュートリアル: 並行処理、リトライ、CSVストリーミング

実戦投入できるNode.jsジオコーディング: p-limitで並行制御、429の指数バックオフ、ストリーミングCSVパイプライン。全コード検証済み。

| April 26, 2026
Node.js ジオコーディングAPIチュートリアル: 並行処理、リトライ、CSVストリーミング

これは、REST API経由で住所をジオコーディングするための実用的なNode.jsチュートリアルだ。この記事のコードは、公開前に書かれ、ファイルにコピペされ、実行されたものだ。すべてのスニペットは、依存パッケージ2つ(p-limit、csv-parse)だけを入れたクリーンなNode 22環境で動く。フレームワークなし、SDKなし、魔法なし。

この記事を読み終えたときに手元に残るもの: 住所のCSVを読み込み、設定可能なレート制限のもとで並行ジオコーディングを行い、429エラーで指数バックオフリトライし、入力ファイルをメモリに丸ごと読み込まずに{lat, lng, error}のCSVを書き出すスクリプト。コードは約80行。

本記事を通じて使うエンドポイントは csv2geo.com/api/v1。無料プランは1日1,000リクエストまでで、クレジットカード不要。手を動かしながら読みたい場合はサインインして /api-keys からキーを取得しよう。前半のサンプルで使うデモエンドポイントはキー不要だ。

エンドポイント

2つのエンドポイントで実務のジオコーディングの95%はカバーできる。Forwardは住所文字列を座標に変換する。Reverseは座標を住所に変換する。どちらもGET(単発)またはPOST(バッチ)を受け付ける。

# Forward (single)
GET https://csv2geo.com/api/v1/geocode?q=ADDRESS&country=US

# Reverse (single)
GET https://csv2geo.com/api/v1/reverse?lat=LAT&lng=LNG

# Batch forward
POST https://csv2geo.com/api/v1/geocode
Body: { "addresses": ["addr1", "addr2", ...] }

# Auth: either ?api_key=KEY query string, or
# Authorization: Bearer KEY header

単発のforwardリクエストのレスポンスはこんな形だ。これはドキュメント用に作った例ではなく、実際の出力。

{
  "query": "1600 Pennsylvania Avenue NW Washington DC",
  "results": [
    {
      "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500-0005, United States",
      "location": { "lat": 38.89768, "lng": -77.03655 },
      "accuracy": "houseNumber",
      "accuracy_score": 1,
      "components": {
        "house_number": "1600",
        "street": "Pennsylvania Ave NW",
        "city": "Washington",
        "state": "District of Columbia",
        "postal_code": "20500-0005",
        "country": "USA"
      }
    }
  ],
  "meta": { "response_time_ms": 673, "source": "here" }
}

クライアントコードを書くときに最も重要なフィールドは2つ。results[0].locationが{lat, lng}だ。results[0].accuracyはマッチレベル—"houseNumber"はルーフトップ精度、"street"は道路セントロイド、"place"はPOIマッチ、"postcode"は郵便番号セントロイド。低confidenceの結果がデータセットを汚す前に、これを使って落とそう。

最初のリクエスト

Node 18以降にはfetchが組み込まれている。最初の呼び出しにはnpm installは不要だ。

// geocode.mjs
const API_KEY = process.env.CSV2GEO_KEY;

async function geocode(address, country = 'US') {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', address);
  url.searchParams.set('country', country);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const data = await res.json();
  return data.results[0]?.location ?? null;
}

console.log(await geocode('1 Apple Park Way, Cupertino, CA'));
// -> { lat: 37.33177, lng: -122.03042 }

実行: CSV2GEO_KEY=geo_live_xxx node geocode.mjs。Authorizationヘッダー方式が推奨だ—この方法ならキーがシェル履歴やアクセスログに残らない。

逆ジオコーディング

形は同じで、パラメータが違うだけ。latとlngを渡すと住所が返ってくる。

async function reverse(lat, lng) {
  const url = new URL('https://csv2geo.com/api/v1/reverse');
  url.searchParams.set('lat', String(lat));
  url.searchParams.set('lng', String(lng));

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.results[0] ?? null;
}

const r = await reverse(48.8584, 2.2945); // Eiffel Tower
console.log(r.formatted_address, r.distance_meters);

逆ジオコーディングのレスポンスにはdistance_metersフィールドが含まれる—マッチした住所が入力座標からどれくらい離れているかだ。配達アプリのGPS pingを逆ジオコーディングしているなら、~50mを超える値はジオコーダーではなくGPSの測位精度が悪かったというサインであることが多い。

何も溶かさずに並列実行する

Nodeで10,000件の住所をジオコーディングする間違ったやり方は、fetchのmapに対してPromise.allを使うことだ。これは10,000本の同時接続をAPIに投げ、2バッチ目でレート制限に引っかかり、reject済みPromiseでイベントループを埋め尽くす。やめよう。

正しいやり方は並行数を制限することだ。この用途で一番きれいに使えるのがp-limit—ソース約50行、推移的依存ゼロ。

npm install p-limit
import pLimit from 'p-limit';

const limit = pLimit(8); // tune to your plan's per-minute rate limit

async function geocode(address) {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', address);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.results[0]?.location ?? null;
}

const addresses = [/* ...10,000 strings... */];
const results = await Promise.all(
  addresses.map(a => limit(() => geocode(a)))
);

並行数のチューニングは経験則だ。無料プランは100リクエスト/分が上限なので、並行数4が安全圏。Starterプランは1,000/分—16〜24が適正レンジ。Proプランは10,000/分—64からスタートしてX-RateLimit-Remainingヘッダーを観察しよう。

リトライ、バックオフ、レート制限ヘッダー

本番のジオコーダーで押さえるべき3点: (1)429と5xxはリトライ可能として扱う、(2)Retry-Afterヘッダーが返ってきたらそれに従う、(3)死んだキーで永遠にループしないように試行回数の上限を設ける。

async function geocodeWithRetry(address, { maxAttempts = 4 } = {}) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const url = new URL('https://csv2geo.com/api/v1/geocode');
    url.searchParams.set('q', address);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
    });

    if (res.ok) return res.json();

    // 4xx other than 429 -> non-retriable (bad key, bad input, etc.)
    if (res.status !== 429 && res.status < 500) {
      throw new Error(`HTTP ${res.status}: ${await res.text()}`);
    }

    // 429 / 5xx -> back off and try again
    const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt;
    await sleep(retryAfter * 1000);
  }
  throw new Error(`Gave up after ${maxAttempts} attempts`);
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

成功した呼び出しでもチェックする価値のあるヘッダーが3つある:

  • X-RateLimit-Limit — プランの分あたり上限。
  • X-RateLimit-Remaining — 現在の窓で残っているリクエスト数。
  • X-RateLimit-Reset — 窓がリセットされるまでのUnix秒。

RemainingがLimitの10%を切ったら、自発的に減速しよう。429で消耗するより安く済むし、パイプライン全体を停止させる急激なバーストよりはるかに安く済む。

バッチエンドポイント

Starter以上のサブスクリプションプランでは、住所の配列を1回のPOSTで送れるバッチエンドポイントが使える。Proでは1リクエストあたり最大10,000件。N回ではなく1往復で済む。無料プランはバッチ非対応—単発をループするしかない。

async function batchGeocode(addresses) {
  const res = await fetch('https://csv2geo.com/api/v1/geocode', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.CSV2GEO_KEY}`,
    },
    body: JSON.stringify({ addresses }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

const out = await batchGeocode([
  '1600 Pennsylvania Ave NW, Washington, DC',
  '1 Apple Park Way, Cupertino, CA',
  '233 S Wacker Dr, Chicago, IL',
]);
// out.results is an array aligned 1:1 with the input order

バッチレスポンスは入力順を保持する。レスポンスのN番目は常に入力のN番目に対応する—idフィールドを往復させる必要はないが、もちろんやってもいい。

OOMせずにCSVをストリーム処理する

入力ファイルが100,000行ある場合、fs.readFileSyncで全部読んで巨大な配列にパースしてmap()するのはやめた方がいい。メモリ不足になるか、レート制限を使い切る。ストリームで処理しよう。

npm install p-limit csv-parse csv-stringify
// stream-geocode.mjs
import { createReadStream, createWriteStream } from 'node:fs';
import { parse } from 'csv-parse';
import { stringify } from 'csv-stringify';
import pLimit from 'p-limit';

const KEY = process.env.CSV2GEO_KEY;
const limit = pLimit(8);

async function geocode(q) {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', q);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
  if (!res.ok) return { lat: '', lng: '', error: `http_${res.status}` };
  const data = await res.json();
  const r = data.results?.[0];
  if (!r) return { lat: '', lng: '', error: 'no_match' };
  if (r.accuracy_score < 0.7) return { lat: '', lng: '', error: 'low_confidence' };
  return { lat: r.location.lat, lng: r.location.lng, error: '' };
}

const out = createWriteStream('out.csv');
const stringer = stringify({ header: true, columns: ['id', 'address', 'lat', 'lng', 'error'] });
stringer.pipe(out);

const tasks = [];
const parser = createReadStream('in.csv').pipe(parse({ columns: true }));
for await (const row of parser) {
  tasks.push(limit(async () => {
    const g = await geocode(row.address);
    stringer.write({ ...row, ...g });
  }));
}
await Promise.all(tasks);
stringer.end();
await new Promise(r => out.on('finish', r));
console.log('done -> out.csv');

このスクリプトが、初心者のコードがやらない3つのことをやっている:

  • 入力をストリームとして読む(ファイル全体を一気にロードしない)し、出力もストリームとして書く(全部バッファに溜めてから一気にflushしない)。
  • p-limit経由で実行中のジオコーディング作業を同時8件までに制限する。
  • 最初の失敗でクラッシュするのではなく、エラーを列に記録する。これが「最後まで完走するスクリプト」と「『どこで止まったか』を聞かれて再起動するハメになるスクリプト」の差だ。

最小構成のTypeScriptラッパー

プロジェクトがTypeScriptなら、レスポンスの型を一度定義して全箇所で使い回そう。下の型は実用的にちょうど狭く、かつAPIに新フィールドが追加されてもビルドが壊れない程度には緩い。

// types.ts
export type Accuracy = 'houseNumber' | 'street' | 'place' | 'postcode';

export interface GeocodeResult {
  formatted_address: string;
  location: { lat: number; lng: number };
  accuracy: Accuracy;
  accuracy_score: number;
  components: {
    house_number?: string;
    street?: string;
    city?: string;
    state?: string;
    postal_code?: string;
    country?: string;
  };
}

export interface GeocodeResponse {
  query: string;
  results: GeocodeResult[];
  meta: { response_time_ms: number; source: string };
}

// client.ts
import type { GeocodeResponse, GeocodeResult } from './types';

export async function geocode(
  q: string,
  country = 'US',
): Promise<GeocodeResult | null> {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', q);
  url.searchParams.set('country', country);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = (await res.json()) as GeocodeResponse;
  return data.results[0] ?? null;
}

指摘しておきたい設計判断が2つ。1つ目、関数はノーマッチ時にthrowせずnullを返す—結果が空なのはエラーではなく、ただの答えだ。2つ目、accuracyをユニオン型にしているので、defaultなしのswitch文に対してAPIが新値を追加するとコンパイルエラーになる。これがAPIのマイナーバージョンアップを生き延びる型付きコードの形だ。

ハマりやすいポイント

単発を並列化したとき順序は保持されない。Promise.allは入力順に返すが、ネットワークは戻ってきた順に完了する。ネットワーク完了順にストリーム書き込みしてしまうと、出力行はシャッフルされる。idをキーにしてストリーム書き込みする(上の例はそうしている—各行が元のidを保持する)か、最後までバッファに溜めるかのどちらかにしよう。

fetchは2xx以外でthrowしない。404や429は解決したPromiseであって、rejectではない。bodyをパースする前に必ずres.okをチェックしよう。「結果がundefinedになるんですが」という問い合わせの大半はこれが原因だ。

国コードは効く。countryパラメータはヒントであってフィルターではない。トロントの住所にcountry=USを渡すと、間違っているがそれっぽいニューヨーク州北部の結果が返ってくる。入力が混在しているなら行ごとにcountryをセットしよう。

空文字列は有効な入力で、ゴミが返ってくる。送信前にrow.addressをバリデートしよう。入力サニタイズに2分かければ「なんでlatが0,0なの?」の調査に1時間使わずに済む。

よくある質問

SDKは必要?

不要。APIはREST + JSONで、Nodeにはfetchが組み込まれている。多くのチームが結局自前で書くことになる「SDK」は、上のgeocodeWithRetry関数とバッチエンドポイント用のラッパー、合わせて約30行だ。

TypeScriptで使える?

使える。上のJSON例からレスポンスの型を起こそう。実用上最低限必要な型は { results: Array<{ location: { lat: number; lng: number }; accuracy: string; accuracy_score: number; components: Record<string, string> }> } だ。もっと厳格にしたいなら、accuracyを"houseNumber" | "street" | "place" | "postcode"の文字列ユニオンに絞ろう。

Nodeではなくブラウザから使える?

できるが、長期有効なAPIキーをブラウザコードに置くのはやめよう—誰でも読める。自前のバックエンド(この記事のコードをそのままExpressやFastifyのルートに入れたもの)でプロキシするか、短命のブラウザスコープのキーを発行するかしよう。

並行数はレート制限に対してどう設定すべき?

プランの分あたりレート制限を60で割って、その値あたりを並行リクエスト数に設定しよう。1,000/分 ÷ 60 ≈ 16。切り捨てる。X-RateLimit-Remainingヘッダーが真の情報源だ—急速に減っていたら調整しよう。

ノーマッチと低confidenceマッチをどう見分ける?

data.resultsはノーマッチのとき空配列だ。低confidenceマッチのときはresults[0]は存在するがaccuracyが"houseNumber"や"street"ではなく"postcode"や"place"になり、accuracy_scoreが1よりかなり低い。しきい値を決めて(0.7が妥当なデフォルト)、それ以下はノーマッチとして扱おう。

米国外の住所をジオコーディングするには?

countryパラメータに正しい国コードを渡そう—ISO alpha-2なので、ドイツはDE、英国はGB、ブラジルはBR。カバレッジは現在39カ国に及び、住所件数トップ10はすべて含まれている。APIページ に国別の件数が掲載されている。

単発リクエストとバッチエンドポイント、どっちを使うべき?

一度に100件以上の住所があり、プランがバッチに対応しているならバッチ。POST 1回は両方にとって100回のGETより安く、レイテンシ削減量はおおむね「平均リクエスト時間 × 件数 ÷ 並行数」だ。住所が時間とともに到着するストリーミングワークロードには単発の方がシンプル。

次に読むもの

APIの完全リファレンスは csv2geo.com/api にある。Pythonで書きたいなら、Pythonチュートリアル がrequestsとasyncioで同じ構成をなぞっている。クライアントコードを書くよりファイルをアップロードしたいワークロードには、一括ジオコーダー が同じバックエンドのWebUI版だ。

X-RateLimitヘッダーがエッジケースで過小カウントになるのを見つけたり、この記事でカバーされていないレスポンス形に当たったら、サイトの問い合わせフォームから人に届く。curlの再現手順つきのバグ報告はすぐ直る。

I.A. / CSV2GEO Creator

関連記事

Pythonで住所をジオコーディングする方法

住所を緯度経度に変換する方法

ジオコーディングAPI価格 — 2026年の実コスト

逆ジオコーディングAPI

一括ジオコーディング

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →