Node.js 地理编码 API 教程:并发、重试与 CSV 流式处理

生产可用的 Node.js 地理编码教程:p-limit 有界并发、429 指数退避重试、流式 CSV 管道,所有代码均经过测试。

| April 26, 2026
Node.js 地理编码 API 教程:并发、重试与 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-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 响应头。

重试、退避与速率限制响应头

生产级地理编码代码必须做到三件事:(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 创始人

相关文章

如何用 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 →