catastrogps 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -25,8 +25,24 @@ var NotFoundError = class extends CatastroGPSError {
25
25
  var ValidationError = class extends CatastroGPSError {
26
26
  name = "ValidationError";
27
27
  };
28
+ var PlaceNameError = class extends ValidationError {
29
+ name = "PlaceNameError";
30
+ get location() {
31
+ const details = this.details;
32
+ return details?.location;
33
+ }
34
+ };
28
35
  var CoverageError = class extends CatastroGPSError {
29
36
  name = "CoverageError";
37
+ get country() {
38
+ return this.details?.country;
39
+ }
40
+ get feature() {
41
+ return this.details?.feature;
42
+ }
43
+ get supportedCountries() {
44
+ return this.details?.supportedCountries ?? [];
45
+ }
30
46
  };
31
47
  var AmbiguousReferenceError = class extends CatastroGPSError {
32
48
  name = "AmbiguousReferenceError";
@@ -37,6 +53,10 @@ var AmbiguousReferenceError = class extends CatastroGPSError {
37
53
  };
38
54
  var QuotaExceededError = class extends CatastroGPSError {
39
55
  name = "QuotaExceededError";
56
+ quota;
57
+ get prepaidBalanceEmpty() {
58
+ return this.quota?.overage?.enabled === true;
59
+ }
40
60
  };
41
61
  var RateLimitError = class extends CatastroGPSError {
42
62
  name = "RateLimitError";
@@ -44,6 +64,7 @@ var RateLimitError = class extends CatastroGPSError {
44
64
  };
45
65
  var ServiceUnavailableError = class extends CatastroGPSError {
46
66
  name = "ServiceUnavailableError";
67
+ retryAfterSeconds;
47
68
  };
48
69
  var ServerError = class extends CatastroGPSError {
49
70
  name = "ServerError";
@@ -54,14 +75,19 @@ var TimeoutError = class extends CatastroGPSError {
54
75
  var NetworkError = class extends CatastroGPSError {
55
76
  name = "NetworkError";
56
77
  };
57
- function errorFromResponse(status, body, retryAfter) {
78
+ function errorFromResponse(status, body, retryAfter, quota) {
58
79
  const code = typeof body.code === "string" ? body.code : void 0;
59
80
  const message = typeof body.error === "string" && body.error || typeof body.message === "string" && body.message || `HTTP ${status}`;
60
81
  const details = body.data ?? body.parsed;
61
82
  const options = { status, code, details, body };
62
83
  if (code === "CNV_AMBIGUOUS" || status === 300) return new AmbiguousReferenceError(message, options);
63
84
  if (code === "CNV_COVERAGE") return new CoverageError(message, options);
64
- if (code === "KEY_AUTH_004") return new QuotaExceededError(message, options);
85
+ if (code === "CNV_PLACE_NAME") return new PlaceNameError(message, options);
86
+ if (code === "KEY_AUTH_004") {
87
+ const error = new QuotaExceededError(message, options);
88
+ error.quota = quota;
89
+ return error;
90
+ }
65
91
  if (status === 429) {
66
92
  const error = new RateLimitError(message, options);
67
93
  error.retryAfterSeconds = retryAfter;
@@ -71,7 +97,11 @@ function errorFromResponse(status, body, retryAfter) {
71
97
  if (status === 403) return new ForbiddenError(message, options);
72
98
  if (status === 404) return new NotFoundError(message, options);
73
99
  if (status === 400 || status === 422) return new ValidationError(message, options);
74
- if (status === 503) return new ServiceUnavailableError(message, options);
100
+ if (status === 503) {
101
+ const error = new ServiceUnavailableError(message, options);
102
+ error.retryAfterSeconds = retryAfter;
103
+ return error;
104
+ }
75
105
  if (status >= 500) return new ServerError(message, options);
76
106
  return new CatastroGPSError(message, options);
77
107
  }
@@ -95,7 +125,75 @@ var ExportResource = class {
95
125
  }
96
126
  };
97
127
 
128
+ // src/types.ts
129
+ var COUNTRY_CODES = [
130
+ "ES",
131
+ "PV",
132
+ "NA",
133
+ "PT",
134
+ "FR",
135
+ "IT",
136
+ "DE",
137
+ "AT",
138
+ "CH",
139
+ "LI",
140
+ "BE",
141
+ "NL",
142
+ "LU",
143
+ "PL",
144
+ "CZ",
145
+ "SK",
146
+ "SI",
147
+ "HR",
148
+ "BG",
149
+ "GR",
150
+ "CY",
151
+ "DK",
152
+ "SE",
153
+ "NO",
154
+ "FI",
155
+ "IS",
156
+ "EE",
157
+ "LV",
158
+ "LT",
159
+ "IE",
160
+ "UK"
161
+ ];
162
+ var ENRICHMENT_COUNTRY_CODES = ["ES", "PV", "NA", "PT", "FR", "IT", "DE"];
163
+ var ADDRESS_COUNTRY_CODES = [
164
+ "ES",
165
+ "FR",
166
+ "IT",
167
+ "DE",
168
+ "AT",
169
+ "NL",
170
+ "BE",
171
+ "PL",
172
+ "CH",
173
+ "CZ",
174
+ "DK",
175
+ "NO",
176
+ "FI",
177
+ "EE",
178
+ "LV",
179
+ "LT",
180
+ "SI",
181
+ "SK",
182
+ "BG",
183
+ "GR",
184
+ "CY",
185
+ "LU",
186
+ "LI",
187
+ "IS",
188
+ "IE",
189
+ "UK",
190
+ "PT"
191
+ ];
192
+ var UNITS_COUNTRY_CODES = ["ES", "PV", "NA"];
193
+
98
194
  // src/resources/parcels.ts
195
+ var MIN_COMPARE = 2;
196
+ var MAX_COMPARE = 3;
99
197
  var ParcelsResource = class {
100
198
  constructor(client) {
101
199
  this.client = client;
@@ -128,14 +226,119 @@ var ParcelsResource = class {
128
226
  });
129
227
  return data.agro ?? data;
130
228
  }
229
+ async terrain(reference, options = {}) {
230
+ return this.client.request("GET", parcelPath(reference, "/terrain"), { query: locatedQuery(options) });
231
+ }
232
+ async groundMotion(reference, options = {}) {
233
+ return this.client.request("GET", parcelPath(reference, "/ground-motion"), { query: locatedQuery(options) });
234
+ }
235
+ async market(reference, options = {}) {
236
+ return this.client.request("GET", parcelPath(reference, "/market"), { query: { country: options.country } });
237
+ }
238
+ async score(reference, options = {}) {
239
+ return this.client.request("GET", parcelPath(reference, "/score"), { query: { country: options.country } });
240
+ }
241
+ async valueHistory(reference, options = {}) {
242
+ return this.client.request("GET", parcelPath(reference, "/value-history"), {
243
+ query: { country: options.country }
244
+ });
245
+ }
246
+ async compare(parcels) {
247
+ if (parcels.length < MIN_COMPARE || parcels.length > MAX_COMPARE) {
248
+ throw new CatastroGPSError(`compare takes ${MIN_COMPARE} or ${MAX_COMPARE} parcels`);
249
+ }
250
+ const parcelas = parcels.map((parcel) => {
251
+ if (!parcel.reference?.trim()) throw new CatastroGPSError("reference must not be empty");
252
+ if (!ENRICHMENT_COUNTRY_CODES.includes(parcel.country)) {
253
+ throw new CatastroGPSError(`country must be one of ${ENRICHMENT_COUNTRY_CODES.join(", ")}`);
254
+ }
255
+ return { ref_catastral: parcel.reference.trim(), country: parcel.country };
256
+ });
257
+ return this.client.request("POST", "/api/catastro/compare", { body: { parcelas } });
258
+ }
131
259
  };
260
+ function locatedQuery(options) {
261
+ const hasLat = options.lat !== void 0;
262
+ const hasLng = options.lng !== void 0;
263
+ if (hasLat !== hasLng) throw new CatastroGPSError("lat and lng go together");
264
+ if (hasLat && (!Number.isFinite(options.lat) || !Number.isFinite(options.lng))) {
265
+ throw new CatastroGPSError("lat and lng must be finite numbers");
266
+ }
267
+ return { country: options.country, lat: options.lat, lng: options.lng };
268
+ }
132
269
  function parcelPath(reference, suffix = "") {
133
270
  if (!reference.trim()) throw new CatastroGPSError("reference must not be empty");
134
271
  return `/api/catastro/${encodeURIComponent(reference.trim())}${suffix}`;
135
272
  }
136
273
 
274
+ // src/resources/fincas.ts
275
+ var MAX_PAGES = 1e3;
276
+ var FincasResource = class {
277
+ constructor(client) {
278
+ this.client = client;
279
+ }
280
+ client;
281
+ async searchAddress(query) {
282
+ const params = typeof query === "string" ? { q: query } : query;
283
+ if (!params.q?.trim() && !params.street?.trim()) throw new CatastroGPSError("q or street is required");
284
+ return this.client.request("GET", "/api/search/address/candidates", {
285
+ query: {
286
+ country: params.country,
287
+ q: params.q?.trim(),
288
+ street: params.street?.trim(),
289
+ number: params.number,
290
+ municipality: params.municipality?.trim(),
291
+ postcode: params.postcode?.trim(),
292
+ limit: params.limit
293
+ }
294
+ });
295
+ }
296
+ async *pages(refcat14, options = {}) {
297
+ const path = parcelPath(refcat14, "/units");
298
+ const previousByCursor = new Map((options.previous?.pages ?? []).map((page) => [page.cursor ?? "", page]));
299
+ let cursor;
300
+ for (let count = 0; count < MAX_PAGES; count += 1) {
301
+ const previous = previousByCursor.get(cursor ?? "");
302
+ const response = await this.client.conditionalRequest("GET", path, {
303
+ query: { cursor, country: options.country },
304
+ ifNoneMatch: previous?.etag
305
+ });
306
+ const data = response.notModified && previous ? previous.data : response.data;
307
+ if (!data) throw new CatastroGPSError("The API answered 304 for a page that was not in the previous import");
308
+ const page = { cursor, etag: response.etag, notModified: response.notModified, data };
309
+ yield page;
310
+ if (!data.truncated || !data.nextCursor) return;
311
+ cursor = data.nextCursor;
312
+ }
313
+ throw new CatastroGPSError(`Stopped after ${MAX_PAGES} pages`);
314
+ }
315
+ async units(refcat14, options = {}) {
316
+ const pages = [];
317
+ for await (const page of this.pages(refcat14, options)) pages.push(page);
318
+ const first = pages[0].data;
319
+ const last = pages[pages.length - 1].data;
320
+ return {
321
+ refCatastral: first.refCatastral,
322
+ direccion: first.direccion,
323
+ codigoPostal: first.codigoPostal,
324
+ municipio: first.municipio,
325
+ provincia: first.provincia,
326
+ usoGeneral: first.usoGeneral,
327
+ anioConstruccion: first.anioConstruccion,
328
+ totalUnidadesFinca: last.totalUnidadesFinca ?? pages.reduce((sum, p) => sum + p.data.unidades.length, 0),
329
+ unidades: pages.flatMap((page) => page.data.unidades),
330
+ construcciones: pages.flatMap((page) => page.data.construcciones ?? []),
331
+ dataSource: first.dataSource,
332
+ dataDate: first.dataDate,
333
+ attribution: first.attribution,
334
+ changed: !pages.every((page) => page.notModified) || pages.length !== (options.previous?.pages.length ?? -1),
335
+ pages
336
+ };
337
+ }
338
+ };
339
+
137
340
  // src/version.ts
138
- var VERSION = "1.0.0";
341
+ var VERSION = "1.2.0";
139
342
 
140
343
  // src/client.ts
141
344
  var DEFAULT_BASE_URL = "https://api.catastrogps.es";
@@ -143,9 +346,12 @@ var DEFAULT_TIMEOUT_MS = 3e4;
143
346
  var DEFAULT_MAX_RETRIES = 2;
144
347
  var BACKOFF_BASE_MS = 500;
145
348
  var RETRYABLE_STATUS = /* @__PURE__ */ new Set([502, 503, 504]);
349
+ var NOT_MODIFIED = 304;
350
+ var MAX_RETRY_AFTER_SECONDS = 60;
146
351
  var CatastroGPS = class {
147
352
  parcels;
148
353
  export;
354
+ fincas;
149
355
  lastQuota;
150
356
  apiKey;
151
357
  baseUrl;
@@ -170,11 +376,25 @@ var CatastroGPS = class {
170
376
  this.fetchImpl = fetchImpl;
171
377
  this.parcels = new ParcelsResource(this);
172
378
  this.export = new ExportResource(this);
379
+ this.fincas = new FincasResource(this);
380
+ }
381
+ async searchAddressCandidates(query) {
382
+ return this.fincas.searchAddress(query);
383
+ }
384
+ async getUnits(refcat14, options = {}) {
385
+ return this.fincas.units(refcat14, options);
386
+ }
387
+ iterUnitPages(refcat14, options = {}) {
388
+ return this.fincas.pages(refcat14, options);
173
389
  }
174
390
  async resolve(text, options = {}) {
175
391
  return this.request("GET", "/api/resolve", { query: { q: text, hint: options.hint } });
176
392
  }
177
393
  async request(method, path, options = {}) {
394
+ const result = await this.conditionalRequest(method, path, options);
395
+ return result.data;
396
+ }
397
+ async conditionalRequest(method, path, options = {}) {
178
398
  const url = this.buildUrl(path, options.query);
179
399
  let attempt = 0;
180
400
  for (; ; ) {
@@ -196,6 +416,7 @@ var CatastroGPS = class {
196
416
  "User-Agent": `catastrogps-js/${VERSION}`
197
417
  };
198
418
  if (options.body !== void 0) headers["Content-Type"] = "application/json";
419
+ if (options.ifNoneMatch) headers["If-None-Match"] = options.ifNoneMatch;
199
420
  let response;
200
421
  try {
201
422
  response = await this.fetchImpl(url, {
@@ -212,15 +433,22 @@ var CatastroGPS = class {
212
433
  } finally {
213
434
  clearTimeout(timer);
214
435
  }
215
- this.captureQuota(response.headers);
436
+ const quota = this.captureQuota(response.headers);
437
+ const etag = response.headers.get("ETag") ?? void 0;
438
+ if (response.status === NOT_MODIFIED) return { etag: etag ?? options.ifNoneMatch, notModified: true, quota };
216
439
  if (!response.ok) {
217
440
  const body2 = await readJson(response);
218
441
  const retryAfter = Number(response.headers.get("Retry-After"));
219
- throw errorFromResponse(response.status, body2, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0);
442
+ throw errorFromResponse(
443
+ response.status,
444
+ body2,
445
+ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0,
446
+ readQuota(response.headers)
447
+ );
220
448
  }
221
- if (options.raw) return await response.arrayBuffer();
449
+ if (options.raw) return { data: await response.arrayBuffer(), etag, notModified: false, quota };
222
450
  const body = await readJson(response);
223
- return "data" in body ? body.data : body;
451
+ return { data: "data" in body ? body.data : body, etag, notModified: false, quota };
224
452
  }
225
453
  buildUrl(path, query) {
226
454
  const url = new URL(`${this.baseUrl}${path}`);
@@ -236,21 +464,56 @@ var CatastroGPS = class {
236
464
  return error instanceof CatastroGPSError && error.status !== void 0 && RETRYABLE_STATUS.has(error.status);
237
465
  }
238
466
  retryDelayMs(error, attempt) {
239
- if (error instanceof RateLimitError && error.retryAfterSeconds) return error.retryAfterSeconds * 1e3;
467
+ if ((error instanceof RateLimitError || error instanceof ServiceUnavailableError) && error.retryAfterSeconds) {
468
+ return Math.min(error.retryAfterSeconds, MAX_RETRY_AFTER_SECONDS) * 1e3;
469
+ }
240
470
  return BACKOFF_BASE_MS * 2 ** attempt;
241
471
  }
242
472
  captureQuota(headers) {
243
- const limit = headers.get("X-RateLimit-Limit");
244
- if (limit === null) return;
245
- const remaining = headers.get("X-RateLimit-Remaining");
246
- this.lastQuota = {
247
- plan: headers.get("X-Quota-Tier") ?? void 0,
248
- limit: Number(limit),
249
- remaining: remaining === null ? void 0 : Number(remaining),
250
- resetsAt: headers.get("X-RateLimit-Reset") ?? void 0
251
- };
473
+ const quota = readQuota(headers);
474
+ if (quota) this.lastQuota = quota;
475
+ return quota;
252
476
  }
253
477
  };
478
+ function readQuota(headers) {
479
+ const quota = {};
480
+ const plan = headers.get("X-Quota-Tier");
481
+ if (plan !== null) quota.plan = plan;
482
+ const limit = numberHeader(headers, "X-Quota-Limit");
483
+ if (limit !== void 0) quota.limit = limit;
484
+ const remaining = numberHeader(headers, "X-Quota-Remaining");
485
+ if (remaining !== void 0) quota.remaining = remaining;
486
+ const resetsAt = headers.get("X-Quota-Reset");
487
+ if (resetsAt !== null) quota.resetsAt = resetsAt;
488
+ if (headers.get("X-Quota-Overage")?.toLowerCase() === "on") {
489
+ quota.overage = {
490
+ enabled: true,
491
+ balance: numberHeader(headers, "X-Quota-Balance"),
492
+ unitPrice: numberHeader(headers, "X-Quota-Overage-Unit-Price"),
493
+ remainingUnits: numberHeader(headers, "X-Quota-Overage-Remaining")
494
+ };
495
+ }
496
+ if (headers.has("X-RateLimit-Limit") || headers.has("X-RateLimit-Remaining")) {
497
+ quota.rateLimit = {
498
+ limit: numberHeader(headers, "X-RateLimit-Limit"),
499
+ remaining: numberHeader(headers, "X-RateLimit-Remaining"),
500
+ resetSeconds: numberHeader(headers, "X-RateLimit-Reset")
501
+ };
502
+ }
503
+ if (headers.has("X-Quota-Finca-Cap")) {
504
+ quota.fincaCap = {
505
+ cap: numberHeader(headers, "X-Quota-Finca-Cap"),
506
+ used: numberHeader(headers, "X-Quota-Finca-Cap-Used")
507
+ };
508
+ }
509
+ return Object.keys(quota).length > 0 ? quota : void 0;
510
+ }
511
+ function numberHeader(headers, name) {
512
+ const raw = headers.get(name);
513
+ if (raw === null || raw.trim() === "") return void 0;
514
+ const value = Number(raw);
515
+ return Number.isFinite(value) ? value : void 0;
516
+ }
254
517
  async function readJson(response) {
255
518
  try {
256
519
  const parsed = await response.json();
@@ -266,43 +529,8 @@ function readEnv(name) {
266
529
  function sleep(ms) {
267
530
  return new Promise((resolve) => setTimeout(resolve, ms));
268
531
  }
269
-
270
- // src/types.ts
271
- var COUNTRY_CODES = [
272
- "ES",
273
- "PV",
274
- "NA",
275
- "PT",
276
- "FR",
277
- "IT",
278
- "DE",
279
- "AT",
280
- "CH",
281
- "LI",
282
- "BE",
283
- "NL",
284
- "LU",
285
- "PL",
286
- "CZ",
287
- "SK",
288
- "SI",
289
- "HR",
290
- "BG",
291
- "GR",
292
- "CY",
293
- "DK",
294
- "SE",
295
- "NO",
296
- "FI",
297
- "IS",
298
- "EE",
299
- "LV",
300
- "LT",
301
- "IE",
302
- "UK"
303
- ];
304
- var ENRICHMENT_COUNTRY_CODES = ["ES", "PV", "NA", "PT", "FR", "IT", "DE"];
305
532
  export {
533
+ ADDRESS_COUNTRY_CODES,
306
534
  AmbiguousReferenceError,
307
535
  AuthenticationError,
308
536
  COUNTRY_CODES,
@@ -316,11 +544,14 @@ export {
316
544
  ForbiddenError,
317
545
  NetworkError,
318
546
  NotFoundError,
547
+ PlaceNameError,
319
548
  QuotaExceededError,
320
549
  RateLimitError,
321
550
  ServerError,
322
551
  ServiceUnavailableError,
323
552
  TimeoutError,
553
+ UNITS_COUNTRY_CODES,
324
554
  VERSION,
325
- ValidationError
555
+ ValidationError,
556
+ readQuota
326
557
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "catastrogps",
3
- "version": "1.0.0",
4
- "description": "Official JavaScript/TypeScript client for the Catastro GPS API: cadastral parcels in 31 European countries and regions by reference, coordinates or Spanish address",
3
+ "version": "1.2.0",
4
+ "description": "Official JavaScript/TypeScript client for the Catastro GPS API: parcels from 26 European cadastres plus the Basque Country and Navarre by reference, coordinates or address; building units, terrain, ground motion, solar and more",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
@@ -28,6 +28,7 @@
28
28
  "build": "tsup src/index.ts --format cjs,esm --dts --clean",
29
29
  "typecheck": "tsc --noEmit",
30
30
  "test": "vitest run",
31
+ "test:coverage": "vitest run --coverage",
31
32
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
32
33
  },
33
34
  "keywords": [
@@ -48,6 +49,9 @@
48
49
  "germany",
49
50
  "real-estate",
50
51
  "proptech",
52
+ "copernicus",
53
+ "natura-2000",
54
+ "ground-motion",
51
55
  "sdk"
52
56
  ],
53
57
  "author": "The Hidden Panda <soporte@catastrogps.es>",
@@ -57,10 +61,11 @@
57
61
  "email": "soporte@catastrogps.es"
58
62
  },
59
63
  "devDependencies": {
64
+ "@types/node": "^22.0.0",
65
+ "@vitest/coverage-v8": "^3.2.7",
60
66
  "tsup": "^8.0.0",
61
67
  "typescript": "^5.4.0",
62
- "vitest": "^3.0.0",
63
- "@types/node": "^22.0.0"
68
+ "vitest": "^3.0.0"
64
69
  },
65
70
  "engines": {
66
71
  "node": ">=18.0.0"