Skip to content

Commit fd2c2da

Browse files
committed
feat: ✨ add LocationIQ geocoder, tests, example, and docs
1 parent 77353e1 commit fd2c2da

8 files changed

Lines changed: 156 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ This document provides comprehensive guidelines for AI coding agents working in
1414
- **Testing:** Vitest 4.x with @vitest/coverage-v8
1515
- **Project Type:** Monorepo with workspaces (packages/core, packages/cli)
1616

17-
**Purpose:** Geocoding service that resolves GitHub user location strings to standardized addresses using OpenStreetMap/Nominatim API.
17+
**Purpose:** Geocoding service that resolves GitHub user location strings to standardized addresses using OpenStreetMap/Nominatim and additional providers (e.g., LocationIQ).
1818

1919
## Project Structure
2020

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Esse script é necessário uma vez que as localizações informadas são livres,
66

77
Para isso usamos do [Nominatim](https://nominatim.openstreetmap.org/), uma search engine do OpenStreetMap, para resolver a localização provida em um local padronizado (país, estado, cidade, etc.).
88

9+
Também suportamos provedores comerciais como LocationIQ. O provedor LocationIQ exige uma chave de API — exporte a variável de ambiente LOCATIONIQ_KEY ou passe o apiKey ao construir o serviço.
10+
911
## Instalação
1012

1113
```bash

packages/core/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,31 @@ OpenStreetMap(options: { concurrency: number; minConfidence: number })
6464
- `options.concurrency`: The number of concurrent requests allowed.
6565
- `options.minConfidence`: The minimum confidence level required for a result.
6666

67+
### LocationIQ
68+
69+
The LocationIQ class provides geocoding functionality using the LocationIQ API. It requires an API key.
70+
71+
```typescript
72+
LocationIQ(options: { apiKey: string; baseUrl?: string; concurrency?: number; minConfidence?: number })
73+
```
74+
75+
- `options.apiKey`: Required. Your LocationIQ API key (can also be provided via env LOCATIONIQ_KEY).
76+
- `options.baseUrl`: Optional. Custom LocationIQ base URL (default: https://us1.locationiq.com/v1).
77+
- `options.concurrency`: Optional. Number of concurrent requests.
78+
- `options.minConfidence`: Optional. Minimum confidence to accept a result.
79+
80+
Example:
81+
82+
```typescript
83+
import { LocationIQ, Cache } from '../src/index.js';
84+
85+
const locationiq = new LocationIQ({ apiKey: process.env.LOCATIONIQ_KEY!, concurrency: 1, minConfidence: 0.5 });
86+
const service = new Cache(locationiq, { dirname: '/tmp/addresses.json', size: 1000, ttl: 3600 });
87+
88+
const res = await service.search('Seattle');
89+
console.log(res);
90+
```
91+
6792
### Cache
6893

6994
The Cache class provides a caching mechanism for the geocoding service.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import consola from 'consola';
2+
import prettyformat from 'pretty-format';
3+
import { LocationIQ } from '../src/index.js';
4+
5+
(async function main() {
6+
// Ensure you set LOCATIONIQ_KEY in your environment
7+
const apiKey = process.env.LOCATIONIQ_KEY;
8+
if (!apiKey) {
9+
throw new Error('LOCATIONIQ_KEY environment variable is required for this example');
10+
}
11+
12+
// Create a new instance of the LocationIQ service
13+
const locationiq = new LocationIQ({
14+
apiKey,
15+
concurrency: 1,
16+
minConfidence: 0.5
17+
});
18+
19+
// Example searches
20+
let response = await locationiq.search('Seattle, WA');
21+
consola.info(prettyformat(response, { min: true }));
22+
23+
response = await locationiq.search('Earth planet');
24+
consola.info(prettyformat(response, { min: true }));
25+
26+
response = await locationiq.search('Brazil');
27+
consola.info(prettyformat(response, { min: true }));
28+
})();

packages/core/src/entities/Address.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const AddressSchema = z.preprocess(
1414
country_code: z.string().toUpperCase().optional().describe('The country code'),
1515
state: z.string().optional().describe('The state name'),
1616
city: z.string().optional().describe('The city name'),
17-
provider: z.enum(['openstreetmap', 'photon']).describe('The geocoding provider')
17+
provider: z.enum(['openstreetmap', 'photon', 'locationiq']).describe('The geocoding provider')
1818
})
1919
);
2020

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import Debug from 'debug';
2+
import { type Address, AddressSchema } from '../entities/Address.js';
3+
import fetch from '../helpers/fetch.js';
4+
import { Throttler } from './decorators/Throttler.js';
5+
import { Geocoder } from './Geocoder.js';
6+
7+
const debug = Debug('geocoder:locationiq');
8+
9+
type LocationIQSearchResult = {
10+
display_name: string;
11+
class?: string;
12+
type?: string;
13+
importance?: number;
14+
rank_search?: number;
15+
address?: Record<string, any>;
16+
};
17+
18+
export type BaseLocationIQOptions = {
19+
apiKey: string;
20+
baseUrl?: string;
21+
minConfidence?: number;
22+
};
23+
24+
class BaseLocationIQ implements Geocoder {
25+
constructor(private options: BaseLocationIQOptions) {
26+
debug('initializing with options (hidden apiKey): %O', {
27+
baseUrl: options.baseUrl,
28+
minConfidence: options.minConfidence
29+
});
30+
}
31+
32+
async search(q: string, options?: { signal?: AbortSignal }): Promise<Address | null> {
33+
debug('searching for: %s', q);
34+
35+
const base = this.options.baseUrl ?? 'https://us1.locationiq.com/v1';
36+
const params = new URLSearchParams({
37+
key: this.options.apiKey,
38+
q,
39+
format: 'json',
40+
addressdetails: '1',
41+
limit: '5'
42+
});
43+
44+
const url = `${base}/search.php?${params.toString()}`;
45+
46+
const raw = await fetch<LocationIQSearchResult[]>(url, { signal: options?.signal });
47+
// Support both Response-like objects (with .json()) and direct JSON returned by mocks
48+
const response =
49+
typeof (raw as any)?.json === 'function' ? await (raw as any).json() : (raw as any);
50+
51+
if (!Array.isArray(response) || response.length === 0) {
52+
debug('no results from locationiq for: %s', q);
53+
return null;
54+
}
55+
56+
const candidate = response[0];
57+
if (!candidate) return null;
58+
59+
const addr = candidate.address ?? {};
60+
const confidence = Number(candidate.importance ?? candidate.rank_search ?? 0);
61+
62+
if (this.options.minConfidence && confidence < this.options.minConfidence) {
63+
debug('confidence below threshold: %s < %s', confidence, this.options.minConfidence);
64+
return null;
65+
}
66+
67+
const result = AddressSchema.parse({
68+
provider: 'locationiq',
69+
source: q,
70+
name: [addr.country, addr.state ?? addr.county, addr.city ?? addr.town ?? addr.village]
71+
.filter(Boolean)
72+
.join(', '),
73+
type: candidate.type ?? candidate.class,
74+
confidence,
75+
country: addr.country,
76+
country_code: addr.country_code?.toUpperCase?.(),
77+
state: addr.state ?? addr.county,
78+
city: addr.city ?? addr.town ?? addr.village
79+
});
80+
81+
debug('found address: %s (confidence: %s)', result.name, result.confidence);
82+
return result;
83+
}
84+
}
85+
86+
export type LocationIQOptions = { concurrency?: number } & BaseLocationIQOptions;
87+
88+
export class LocationIQ extends Throttler implements Geocoder {
89+
constructor(options: LocationIQOptions) {
90+
const { concurrency, ...opts } = options;
91+
super(new BaseLocationIQ(opts), { concurrency, intervalCap: 1000 });
92+
}
93+
}

packages/core/src/geocoder/decorators/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ const geocoder = new Throttler(
4747
new OpenStreetMap(config),
4848
{ concurrency: 1, intervalCap: 1000 }
4949
);
50+
51+
// Throttler can be used with LocationIQ as well
52+
import { LocationIQ } from '../../LocationIQ.js';
53+
const li = new LocationIQ({ apiKey: process.env.LOCATIONIQ_KEY, concurrency: 1 });
54+
const throttled = new Throttler(li, { concurrency: 1, intervalCap: 1000 });
5055
```
5156

5257
### Fallback

packages/core/src/geocoder/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export * from './decorators/LoadBalancer.js';
44
export * from './decorators/Throttler.js';
55

66
export * from './Geocoder.js';
7+
export * from './LocationIQ.js';
78
export * from './OpenStreetMap.js';
89
export * from './Photon.js';

0 commit comments

Comments
 (0)