Node.js 地理编码 API 教程:并发、重试与 CSV 流式处理
生产可用的 Node.js 地理编码教程:p-limit 有界并发、429 指数退避重试、流式 CSV 管道,所有代码均经过测试。
这是一篇可运行的 Node.js 教程,用于通过 REST API 对地址进行地理编码。本文中的所有代码都是先编写、复制粘贴到文件、运行通过后才发布的。每段代码都能在干净的 Node 22 环境上跑起来,只需要两个依赖(p-limit、csv-parse)。没有框架、没有 SDK,也没有黑魔法。
读完本文你会得到这样一个脚本:它读取一个地址 CSV,以可配置的速率限制并发地理编码,在收到 429 时使用指数退避重试,并将 {lat, lng, error} 写入输出 CSV——全程不把输入文件加载到内存。代码大约 80 行。
本文中使用的端点是 csv2geo.com/api/v1。免费层每天 1,000 次请求,无需信用卡。如果你想跟着动手,先登录并到 /api-keys 获取一把密钥;前面几个示例使用的演示端点不需要密钥。
端点
两个端点就能覆盖现实世界中 95% 的地理编码工作。正向把地址字符串转换为坐标。反向把坐标转换为地址。两者都接受 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单条正向请求的响应结构如下。这是真实输出,不是文档示例。
{
"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" }
}编写客户端代码时最重要的两个字段:results[0].location 就是你要的 {lat, lng};results[0].accuracy 是匹配级别——"houseNumber" 是屋顶级,"street" 是街道中心点,"place" 是兴趣点匹配,"postcode" 是邮编中心点。用它来过滤掉低置信度结果,避免污染你的数据集。
第一次请求
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 请求头的写法——这样密钥就不会出现在 shell 历史或访问日志里。
反向地理编码
结构相同,参数不同。传入 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 字段——匹配到的地址离输入坐标有多远。如果你正在对配送 App 的 GPS 信号做反向地理编码,只要超过 50 米左右,通常意味着 GPS 定位不准,而不是地理编码器出错。
并行处理而不让一切爆炸
在 Node 中给 1 万个地址做地理编码,错误的方式是对一组 fetch 用 Promise.all。这会同时向 API 打开 1 万条连接,在第二批就触发速率限制,然后把事件循环填满已拒绝的 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 响应头。
重试、退避与速率限制响应头
生产级地理编码代码必须做到三件事:(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));每次成功调用还会暴露三个值得检查的响应头:
- X-RateLimit-Limit——你套餐的每分钟上限。
- X-RateLimit-Remaining——当前窗口内剩余的请求数。
- X-RateLimit-Reset——窗口重置的 Unix 秒时间戳。
如果 Remaining 跌到 Limit 的 10% 以下,主动放慢速度。这比硬撞 429 便宜,远比突如其来的爆发把整条流水线打挂便宜。
批量端点
从 Starter 起的订阅档位都可以使用批量端点,一次 POST 提交一个地址数组。Pro 每次最多 10,000 条。一次往返代替 N 次。免费层不允许批量——只能循环单条调用。
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 字段,但你也可以这么做。
流式处理 CSV 而不爆内存
如果输入文件有 10 万行,你不会想用 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');这个脚本做了三件初学者代码通常不做的事:
- 输入按流读取(不一次性加载整个文件),输出按流写入(不先全部缓冲再一次性 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;
}两个值得指出的设计选择。其一,函数在没有匹配时返回 null 而不是抛错——空结果不是错误,只是一个答案。其二,accuracy 被定义为联合类型,因此对它做 switch 而不写 default 分支时,如果 API 将来新增了某个值,就会变成编译错误。这种类型化代码能在 API 小版本升级中持续存活。
容易踩的坑
并发处理单条请求时,顺序不会被保留。Promise.all 按输入顺序返回,但网络层是哪个请求先回来就先完成。如果你按网络完成顺序流式写入结果,输出行的顺序就乱了。要么按 id 流式写入(上面的例子就是这样——每行带着自己的原始 id),要么先缓冲到最后再一次性写出。
fetch 在非 2xx 时不会抛错。404 或 429 是已 resolve 的 Promise,不是 reject。解析响应体之前一定要检查 res.ok。"为什么我的结果是 undefined"工单中很大一部分都源于此。
国家代码很重要。country 参数是提示,不是过滤器。如果你给一个多伦多的地址传 country=US,会拿到一个错误但看起来合理的纽约州北部的结果。当输入是混合时,逐行设置 country。
空字符串是合法输入,会返回垃圾结果。在发送之前校验 row.address。两分钟的输入清洗能省下一小时"为什么 lat 是 0,0"的排查时间。
常见问题
需要 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 响应头是真相来源——看到它掉得快就调小并发。
如何区分"未匹配"与"成功的低置信度匹配"?
未匹配时 data.results 是空数组。低置信度匹配时 results[0] 存在,但 accuracy 是 "postcode" 或 "place" 而不是 "houseNumber" 或 "street",并且 accuracy_score 远低于 1。设定一个阈值(0.7 是合理默认值),低于该值的都视作未匹配。
如何对美国以外的地址做地理编码?
在 country 参数里传入正确的国家代码——ISO alpha-2,例如德国 DE、英国 GB、巴西 BR。目前覆盖 39 个国家,包含按地址数排名前 10 的全部国家。API 页面 列出了每个国家的地址数。
应该用单条请求还是批量端点?
当你一次要处理 ≥100 个地址,且套餐允许时,使用批量。一次 POST 对双方都比 100 次 GET 划算,延迟收益大致等于平均单条耗时乘以请求数再除以并发数。如果是地址陆续到达的流式负载,单条请求更简单。
接下来去哪
API 的完整参考文档在 csv2geo.com/api。如果你更想用 Python,Python 教程 采用相同结构,使用 requests 和 asyncio。如果你想上传文件而不是写客户端代码,批量地理编码 就是同一套后端,只是套了一个网页 UI。
如果你发现 X-RateLimit 响应头在某些边界情况下计数偏少,或者遇到本文未覆盖的响应结构,网站上的联系表单会送达一个真人。带 curl 复现的 Bug 报告会被快速修复。
I.A. / CSV2GEO 创始人
相关文章
• 批量地理编码
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →