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

一括ジオコーディング

More in Developers →
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 →