Node.js tutorial за Geocoding API: concurrency, retries и CSV streaming
Production-ready Node.js tutorial за геокодиране: bounded concurrency с p-limit, retry/backoff при 429 и streaming CSV pipeline. Целият код е тестван.
Това е работещ Node.js tutorial за геокодиране на адреси през REST API. Кодът в тази статия е писан, копиран в файлове и изпълняван преди публикуване. Всеки snippet се изпълнява на чиста инсталация на Node 22 с две зависимости (p-limit, csv-parse). Без frameworks, без SDK, без магия.
Какво ще получите накрая: скрипт, който чете CSV с адреси, геокодира ги конкурентно с конфигурируем rate limit, прави retry при 429 с exponential backoff и записва CSV с {lat, lng, error}, без да зарежда входния файл в паметта. Около 80 реда код.
Endpoint-ът, който се използва навсякъде, е csv2geo.com/api/v1. Безплатният план е 1 000 заявки на ден, без банкова карта. Влезте и вземете key от /api-keys, ако искате да следвате примерите; demo endpoint-ите от ранните примери не изискват key.
Endpoint-ът
Два endpoint-а покриват 95% от реалната работа по геокодиране. Forward превръща адресен низ в координати. Reverse превръща координати в адрес. И двата приемат GET (единичен) или POST (batch).
# 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" }
}Две полета са най-важни, когато пишете клиентски код. results[0].location е вашето {lat, lng}. results[0].accuracy е нивото на съвпадение — "houseNumber" е rooftop, "street" е центроид на улицата, "place" е POI съвпадение, "postcode" е центроид на пощенски код. Използвайте го, за да отрязвате резултати с ниска увереност, преди да замърсят dataset-а ви.
Първа заявка
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 header е препоръчителната — keys никога не попадат в shell history-то ви или в access лог-овете по този начин.
Reverse геокодиране
Същата форма, различни параметри. Подавате 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);Reverse отговорите включват поле distance_meters — на колко метра е съпоставеният адрес от входната координата. Ако правите reverse геокодиране на GPS пингове от приложение за доставки, всичко над ~50м обикновено означава, че GPS-ът е дал лош fix, а не че геокодерът греши.
Как да го направим паралелно, без нищо да гръмне
Грешният начин да геокодирате 10 000 адреса в Node е Promise.all върху map от fetch-ове. Това изстрелва 10 000 конкурентни връзки към API, удря rate limit-а на втория batch и пълни event loop-а ви с rejected promises. Не го правете.
Правилният начин е bounded concurrency. Най-чистият инструмент за работата е p-limit — около 50 реда код, без transitive dependencies.
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)))
);Тунинг-ът на concurrency е емпиричен. Безплатният план е таван от 100 заявки на минута, така че concurrency 4 е безопасно. Starter планът е 1 000/мин — 16 до 24 е добър диапазон. Pro планът е 10 000/мин — започнете от 64 и следете header-а X-RateLimit-Remaining.
Retry, backoff и rate-limit headers
Три неща в реален production геокодер: (1) третирайте 429 и 5xx като retryable, (2) спазвайте Retry-After header-а, когато го има, (3) ограничете броя опити, така че трайно мъртъв key да не зацикли завинаги.
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));Отговорът също експонира три header-а, които си струва да се проверяват при всяко успешно повикване:
- X-RateLimit-Limit — таванът на плана ви на минута.
- X-RateLimit-Remaining — колко ви остават в текущия прозорец.
- X-RateLimit-Reset — Unix секунди до reset на прозореца.
Ако Remaining падне под 10% от Limit, забавете доброволно. По-евтино е, отколкото да блъскате в 429-ки и далеч по-евтино от внезапен burst, който сваля целия pipeline.
Batch endpoint-ът
Абонаментните нива от Starter нагоре експонират batch endpoint, който приема масив от адреси в един POST. До 10 000 на заявка на Pro. Един round-trip вместо N. Безплатният план не позволява batch — трябва да loop-вате singles.
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 orderBatch отговорите запазват входния ред. Позиция N в отговора винаги съответства на позиция N във входа — няма нужда да въртите id поле, въпреки че можете.
Streaming на CSV без OOM
Ако входният ви файл е 100 000 реда, не искате да го fs.readFileSync-нете, да го parsenete в гигантски масив и след това да направите map() върху резултата. Ще ви свърши паметта или ще изгорите rate limit-а. Stream-вайте го.
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');Три неща, които този скрипт прави и които код на начинаещи обикновено не прави:
- Чете входа като stream (без зареждане на целия файл) и пише изхода като stream (без buffer-everything-then-flush).
- Ограничава in-flight геокодиращата работа до 8 конкурентни заявки чрез p-limit.
- Записва грешките в колона, вместо да крашва на първата неуспешна заявка. Това е разликата между скрипт, който завършва, и скрипт, който трябва да се рестартира с въпрос „докъде стигна?".
Минимален TypeScript wrapper
Ако проектът ви е TypeScript, опишете формата на отговора веднъж и я преизползвайте навсякъде. Типовете по-долу са достатъчно тесни, за да са полезни, и достатъчно свободни, за да не счупят build-а при бъдещи 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 при no-match, вместо да хвърля — празни резултати не е грешка, просто отговор. Второ, accuracy е типизиран като union, така че switch върху него без default branch е compile грешка, ако API някога добави нова стойност. Това е типизиран код, който продължава да оцелява през minor версии на API-то.
Неща, които хапят хората
Редът не се запазва, когато паралелизирате singles. Promise.all връща в реда на входа, но мрежата завършва която заявка се върне първа. Ако stream-write-вате резултати в реда на завършване, изходните ви редове ще са разбъркани. Или stream-write-вайте по id (примерът горе прави точно това — всеки ред носи оригиналното си id), или buffer-вайте до края.
fetch не хвърля при non-2xx. 404 или 429 е resolved promise, не rejection. Винаги проверявайте res.ok преди да парсвате body-то. Броят на „защо резултатите ми са undefined" тикетите, които стигат до точно това, е голям.
Country кодовете имат значение. Параметърът country е hint, не filter. Ако подадете country=US за адрес в Торонто, ще получите грешен, но правдоподобен резултат някъде в северен Ню Йорк. Задавайте country за всеки ред, когато входът ви е смесен.
Празните стрингове са валиден вход, който връща боклук. Валидирайте row.address преди изпращане. Две минути санитация на входа спестяват час разследване „защо lat е 0,0?".
Често задавани въпроси
Трябва ли ми SDK?
Не. API-то е REST + JSON, а Node има вграден fetch. „SDK"-то, което повечето екипи накрая пишат, е функцията geocodeWithRetry отгоре плюс wrapper за batch endpoint-а. Около 30 реда.
Работи ли с TypeScript?
Да. Типизирайте формата на отговора от примерния JSON отгоре. Минималните полезни типове са { results: Array<{ location: { lat: number; lng: number }; accuracy: string; accuracy_score: number; components: Record<string, string> }> }. Ако искате по-строго, стеснете accuracy до string union "houseNumber" | "street" | "place" | "postcode".
Мога ли да го използвам от browser вместо Node?
Можете, но не трябва да слагате дългоживеещ API key в browser код — всеки може да го прочете. Или ползвайте proxy през собствен backend (същият код от тази статия, само вътре в Express или Fastify route), или издавайте краткоживеещи browser-scoped keys.
Какъв rate limit да използвам за concurrency?
Вземете rate limit-а на плана си на минута, разделете на 60 и целете приблизително толкова конкурентни заявки. 1 000/мин ÷ 60 ≈ 16. Закръглете надолу. Header-ът X-RateLimit-Remaining е източникът на истината — настройвайте, ако го виждате как пада бързо.
Как да различа no-match от успешен low-confidence match?
data.results е празен масив при no-match. При low-confidence match, results[0] съществува, но accuracy е "postcode" или "place", вместо "houseNumber" или "street", а accuracy_score е доста под 1. Решете праг (0.7 е разумно по подразбиране) и третирайте всичко под него като no-match.
Как да геокодирам адреси извън САЩ?
Подайте правилния country код в параметъра country — ISO alpha-2, така че DE за Германия, GB за Великобритания, BR за Бразилия. Покритието обхваща 39 страни днес, включително пълната топ 10 по брой адреси. API страницата показва броя на адресите по страна.
Да използвам ли единични заявки или batch endpoint?
Batch, когато имате ≥100 адреса наведнъж и планът ви го позволява. Един POST е по-евтин и за двете страни от 100 GET-а, а спестяването на latency е приблизително средното време на заявка по броя заявки, разделено на concurrency. Singles са по-прости за streaming натоварвания, където адресите пристигат с времето.
Накъде оттук
Пълната референция за API-то е на csv2geo.com/api. Ако предпочитате Python, Python tutorial-ът следва същата структура с requests и asyncio. За натоварвания, при които искате да качите файл, вместо да пишете клиентски код, batch geocoder-ът е същият backend зад web UI.
Ако намерите, че X-RateLimit header-ите броят грешно в някой edge case, или попаднете на форма на отговор, която тази статия не покрива, формата за контакт на сайта стига до човек, който я чете. Bug reports с curl reproductions се оправят бързо.
I.A. / CSV2GEO Creator
Свързани статии
• Как да геокодирате адреси в Python
• Как да превърнете адрес в lat long
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →