Node.js ジオコーディングAPIチュートリアル: 並行処理、リトライ、CSVストリーミング
実戦投入できるNode.jsジオコーディング: p-limitで並行制御、429の指数バックオフ、ストリーミング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-limitimport 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
関連記事
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →