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

批量地理编码

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 →