keepa-api 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -1,50 +1,20 @@
1
1
  # keepa-api
2
2
 
3
- Lightweight TypeScript SDK for the [Keepa](https://keepa.com) REST API. Mirrors the organizational style of [openai-node](https://github.com/openai/openai-node) — a single `KeepaClient` class exposes resources (`products`, etc.) that wrap each endpoint with typed inputs and responses.
4
-
5
- Phase 1 ships the **Products** resource only. Categories, Search, and Bestsellers are planned but not yet implemented.
6
-
7
- ## Requirements
8
-
9
- - Node.js **≥ 18** (uses native `fetch`)
10
- - A Keepa API key — sign up at [keepa.com/api](https://keepa.com/api.html)
11
- - ESM consumer (your `package.json` should have `"type": "module"`, or you must be able to `import()` an ESM package)
3
+ Lightweight TypeScript SDK for the [Keepa](https://keepa.com) REST API. A single `KeepaClient` class exposes typed resources (currently `products`) with parsed responses, friendly currency units, and live rate-limit visibility.
12
4
 
13
5
  ## Installation
14
6
 
15
- This package is **not yet published to npm**. Install it locally or from GitHub.
16
-
17
- ### From a local checkout (during development)
18
-
19
7
  ```bash
20
- # In keepa-api/
21
- npm install
22
- npm run build
23
- npm link
24
-
25
- # In your other project:
26
- npm link keepa-api
8
+ npm install keepa-api
9
+ # or
10
+ pnpm add keepa-api
11
+ # or
12
+ yarn add keepa-api
13
+ # or
14
+ bun add keepa-api
27
15
  ```
28
16
 
29
- After this, edits in `keepa-api/src/` only need `npm run build` the consumer sees the new `dist/` immediately.
30
-
31
- ### From GitHub (no npm publish needed)
32
-
33
- ```bash
34
- npm install github:bilalyasin1616/keepa-api#feature/keepa-products-package
35
- ```
36
-
37
- (Replace the branch with `main` once the work is merged.)
38
-
39
- ### From a tarball
40
-
41
- ```bash
42
- # In keepa-api/
43
- npm pack # produces keepa-api-0.1.0.tgz
44
-
45
- # In your other project:
46
- npm install /absolute/path/to/keepa-api-0.1.0.tgz
47
- ```
17
+ Requires Node 18+. Ships both ESM and CJSworks with `import` and `require`.
48
18
 
49
19
  ## Quickstart
50
20
 
@@ -53,19 +23,18 @@ import KeepaClient from 'keepa-api';
53
23
 
54
24
  const keepa = new KeepaClient({ apiKey: process.env.KEEPA_API_KEY });
55
25
 
56
- const products = await keepa.products.list({
57
- asins: ['B00MNV8E0C'],
58
- marketplace: 'US',
59
- });
26
+ const [product] = await keepa.products.list({ asins: ['B00MNV8E0C'] });
60
27
 
61
- console.log(products[0]?.title);
28
+ console.log(product?.title, product?.bsr, product?.images[0]);
62
29
  ```
63
30
 
64
- The client reads `process.env.KEEPA_API_KEY` if you don't pass `apiKey` explicitly. Put your key in a gitignored `.env` (see `.env.example` for the shape).
31
+ `apiKey` falls back to `process.env.KEEPA_API_KEY` when omitted.
65
32
 
66
- ## API
33
+ ## Client
67
34
 
68
- ### `new KeepaClient(options?)`
35
+ ```ts
36
+ new KeepaClient(options?)
37
+ ```
69
38
 
70
39
  | Option | Type | Default | Notes |
71
40
  |--------|------|---------|-------|
@@ -73,138 +42,167 @@ The client reads `process.env.KEEPA_API_KEY` if you don't pass `apiKey` explicit
73
42
  | `baseURL` | `string` | `'https://api.keepa.com'` | Override for testing/proxying. |
74
43
  | `fetch` | `typeof fetch` | `globalThis.fetch` | Plug in a custom fetch (mocks, retries, etc.). |
75
44
 
45
+ ## Products
46
+
76
47
  ### `keepa.products.list(params)` → `Promise<KeepaProduct[]>`
77
48
 
78
49
  | Param | Type | Default | Notes |
79
50
  |-------|------|---------|-------|
80
51
  | `asins` | `string[]` | (required) | Validated + uppercased. Throws on malformed input. |
81
- | `marketplace` | `'US' \| 'GB' \| 'DE' \| 'FR' \| 'JP' \| 'CA' \| 'IT' \| 'ES' \| 'IN' \| 'MX' \| 'BR'` | `'US'` | Case-insensitive. Throws on unknown. |
82
- | `days` | `number` | `1` | Days of price history. Must be a positive integer (validated pre-flight). |
52
+ | `marketplace` | `Marketplace` | `'US'` | Case-insensitive. See [marketplaces](#marketplaces). |
53
+ | `days` | `number` | `1` | Days of csv history when `history: true`. |
54
+ | `history` | `boolean` | `false` | Populates `history.price.*` + scalar `amazonPrice` / `newPrice` / `listPrice`. Costs extra tokens. |
55
+
56
+ ### `keepa.products.retrieve(params)` → `Promise<KeepaProduct>`
57
+
58
+ Same options as `list`, but takes a single `asin: string` and returns one product. Throws `ProductNotFoundError` when Keepa has no record (stub with `title === null`, or no product returned at all).
83
59
 
84
- The SDK maps Keepa's raw wire shape into a friendlier `KeepaProduct`:
60
+ ### `KeepaProduct`
85
61
 
86
- - `images: string[]` — full image URLs (region-neutral CDN). Replaces Keepa's awkward `imagesCSV` string.
87
- - `bsr: number | null` — most recent real BSR for the product's `rootCategory`. `null` when missing or every history entry is Keepa's `-1` "no data captured" sentinel.
62
+ The SDK reshapes Keepa's wire format into something usable:
88
63
 
89
- The raw `salesRanks` record is preserved for consumers that need to walk the full rank history. Other Keepa fields (`title`, `parentAsin`, `categoryTree`, `variations`, `features`, …) pass through unchanged.
64
+ | Field | Type | Notes |
65
+ |-------|------|-------|
66
+ | `images` | `string[]` | Full image URLs (region-neutral CDN). |
67
+ | `bsr` | `number \| null` | Latest non-sentinel BSR for `rootCategory`. |
68
+ | `amazonPrice` | `number \| null` | Latest price Amazon itself sells the product for (csv[0]) in the marketplace's **major unit** — dollars/pounds/yen/…. Null without `history: true`. |
69
+ | `newPrice` | `number \| null` | Latest lowest-3rd-party-new offer price (csv[1]). Distinct from `amazonPrice` — Amazon isn't always the cheapest new seller. |
70
+ | `listPrice` | `number \| null` | Latest list price / MSRP (csv[4]). |
71
+ | `history.price.amazon` | `PriceHistoryEntry[]` | Amazon's own price over time. Empty without `history: true`. |
72
+ | `history.price.new` | `PriceHistoryEntry[]` | Lowest-3rd-party-new price over time. |
73
+ | `history.price.list` | `PriceHistoryEntry[]` | List price (MSRP) over time. |
90
74
 
91
- **Stub records:** Keepa returns one record per requested ASIN even when it has no data — these stubs have `title === null`. Filter with `isFoundProduct` (below).
75
+ `title`, `description`, `parentAsin`, `categoryTree`, `salesRanks`, `variations`, `features` pass through from Keepa unchanged.
92
76
 
93
- ### Helpers
77
+ **Stub records:** Keepa returns one entry per requested ASIN even for unknown ASINs — those stubs have `title === null`. Filter them out:
94
78
 
95
79
  ```ts
96
80
  import { isFoundProduct } from 'keepa-api';
97
81
 
98
- const real = products.filter(isFoundProduct);
82
+ const found = products.filter(isFoundProduct);
83
+ ```
99
84
 
100
- for (const product of real) {
101
- console.log(product.bsr); // already populated by the SDK
102
- console.log(product.images[0]); // already populated by the SDK
103
- }
85
+ ### Price history
86
+
87
+ ```ts
88
+ const [product] = await keepa.products.list({
89
+ asins: ['B00MNV8E0C'],
90
+ history: true,
91
+ days: 30,
92
+ });
93
+
94
+ product.amazonPrice; // 18.99 (USD) — Amazon's own latest
95
+ product.newPrice; // 16.99 — cheapest 3rd-party new
96
+ product.listPrice; // 29.99 — MSRP
97
+ product.history.price.amazon[0]; // { timestamp: Date, price: 19.49 }
104
98
  ```
105
99
 
106
- | Helper | Signature | Returns |
107
- |--------|-----------|---------|
108
- | `isFoundProduct(product)` | `(product: KeepaProduct) => boolean` | `true` only if Keepa returned real data (stubs have `title === null`). |
109
- | `parseImagesCsv(csv)` | `(csv: string \| undefined) => string[]` | Build the full image-URL list from a raw Keepa imagesCSV. Used internally to fill `images`; exported for advanced use. |
110
- | `extractBsr(salesRanks, rootCategory)` | `(salesRanks: Record<string, number[]> \| undefined, rootCategory: number \| undefined) => number \| null` | Most recent real BSR from Keepa's raw `[ts, rank, ...]` history. Used internally to fill `bsr`; exported for advanced use. |
111
- | `extractImageUrl(imagesCSV)` | `(imagesCSV: string \| undefined) => string \| null` | Single URL — equivalent to `parseImagesCsv(imagesCSV)[0] ?? null`. Exported for advanced use. |
100
+ Prices are in the marketplace's major unit (USD dollars for `'US'`, GBP for `'GB'`, JPY for `'JP'`, …). Keepa's `-1` "no data captured" entries are filtered out.
112
101
 
113
- ### ASIN validation
102
+ For csv types beyond Amazon / list price (`NEW`, `USED`, `REFURBISHED`, `RATING`, …), pull the row off the raw response and parse it:
114
103
 
115
104
  ```ts
116
- import { ASIN_REGEX, ASIN_LENGTH, isValidAsin, normalizeAsins } from 'keepa-api';
105
+ import { CsvType, parsePriceHistory } from 'keepa-api';
117
106
 
118
- isValidAsin('B00MNV8E0C'); // true
119
- isValidAsin('b00mnv8e0c'); // false (lowercase — use normalizeAsins to coerce)
120
- isValidAsin('B07XYZ'); // false (too short)
107
+ const usedPrices = parsePriceHistory(rawProduct.csv?.[CsvType.USED]);
108
+ ```
121
109
 
122
- normalizeAsins([' b00mnv8e0c ']); // ['B00MNV8E0C']
123
- normalizeAsins(['B07XYZ']); // throws: Invalid ASIN(s): B07XYZ. ...
110
+ `CsvType` covers all 36 of Keepa's csv series names verbatim.
111
+
112
+ ## Marketplaces
113
+
114
+ ```ts
115
+ import { resolveDomainId } from 'keepa-api';
116
+
117
+ resolveDomainId('gb'); // 2 (case-insensitive)
118
+ resolveDomainId(undefined); // 1 (defaults to US)
124
119
  ```
125
120
 
126
- `Products.list` calls `normalizeAsins` for you, so you don't need to pre-validate unless you're doing form-level checking.
121
+ | Code | Domain | Code | Domain |
122
+ |------|--------|------|--------|
123
+ | US | 1 | IT | 8 |
124
+ | GB | 2 | ES | 9 |
125
+ | DE | 3 | IN | 10 |
126
+ | FR | 4 | MX | 11 |
127
+ | JP | 5 | BR | 12 |
128
+ | CA | 6 | | |
127
129
 
128
- **Caveat:** The regex catches *malformed* input. It cannot catch "Keepa has no record" — for example `1234567890` is a structurally valid ISBN-10 shape and passes the regex, but Keepa returns a stub record. Use `isFoundProduct` to filter those.
130
+ Codes are ISO 3166-1 alpha-2. Domain 7 is reserved (formerly Amazon China, retired by Keepa).
129
131
 
130
- ### Marketplaces
132
+ ## Rate limits
133
+
134
+ The client tracks Keepa's token bucket on every response — success and 429 alike — and exposes the latest snapshot:
131
135
 
132
136
  ```ts
133
- import { MARKETPLACE_DOMAINS, resolveDomainId } from 'keepa-api';
137
+ await keepa.products.list({ asins });
134
138
 
135
- MARKETPLACE_DOMAINS.US; // 1
136
- resolveDomainId('gb'); // 2 (case-insensitive)
137
- resolveDomainId(undefined); // 1 (defaults to US)
139
+ if (keepa.rateLimit && keepa.rateLimit.tokensLeft < 50) {
140
+ await sleep(keepa.rateLimit.refillIn); // ms until next token refills
141
+ }
142
+ ```
143
+
144
+ On a 429, the snapshot is also attached to the thrown error:
145
+
146
+ ```ts
147
+ import { RateLimitError } from 'keepa-api';
148
+
149
+ try {
150
+ await keepa.products.list({ asins: bigBatch });
151
+ } catch (err) {
152
+ if (err instanceof RateLimitError && err.rateLimit) {
153
+ await sleep(err.rateLimit.refillIn);
154
+ // retry…
155
+ }
156
+ }
138
157
  ```
139
158
 
140
- | Code | Domain ID | Code | Domain ID |
141
- |------|-----------|------|-----------|
142
- | US | 1 | IT | 8 |
143
- | GB | 2 | ES | 9 |
144
- | DE | 3 | IN | 10 |
145
- | FR | 4 | MX | 11 |
146
- | JP | 5 | BR | 12 |
147
- | CA | 6 | | |
159
+ `RateLimitInfo`: `tokensLeft`, `refillIn` (ms), `refillRate` (tokens/min), `tokenFlowReduction`, `receivedAt`.
148
160
 
149
- ### Errors
161
+ The SDK does **not** auto-retry on 429. Handling that is your call.
150
162
 
151
- All errors thrown by API calls extend `KeepaError`. Catch the specific subclasses for status-aware handling:
163
+ ## Errors
164
+
165
+ All errors extend `KeepaError`:
152
166
 
153
167
  ```ts
154
168
  import {
169
+ KeepaError,
170
+ APIError,
155
171
  RateLimitError,
156
172
  AuthenticationError,
157
- APIError,
158
173
  NetworkError,
159
- KeepaError,
160
174
  } from 'keepa-api';
161
175
 
162
176
  try {
163
- await keepa.products.list({ asins: ['B00MNV8E0C'] });
177
+ await keepa.products.list({ asins });
164
178
  } catch (err) {
165
- if (err instanceof RateLimitError) /* 429 */;
166
- else if (err instanceof AuthenticationError) /* 401 — bad API key */;
167
- else if (err instanceof APIError) /* 4xx/5xx — err.status, err.body */;
168
- else if (err instanceof NetworkError) /* DNS/ECONNREFUSED/abort — err.cause has the original */;
169
- else if (err instanceof KeepaError) /* something else from this SDK */;
179
+ if (err instanceof RateLimitError) /* 429 — err.rateLimit */;
180
+ else if (err instanceof AuthenticationError) /* 401 — invalid API key */;
181
+ else if (err instanceof APIError) /* 4xx/5xx — err.status, err.body */;
182
+ else if (err instanceof NetworkError) /* transport — err.cause */;
170
183
  else throw err;
171
184
  }
172
185
  ```
173
186
 
174
- ### API key handling
175
-
176
- Keepa's REST API requires the key as a `?key=...` query parameter — that's their contract, not a choice we made. Practical implications:
187
+ ## ASIN utilities
177
188
 
178
- - **Server-side proxy / access logs** will record full URLs (key included). Configure log scrubbing if you can't trust the layer.
179
- - **Don't run this in the browser.** A client-side request would expose your key in DevTools' Network tab and to any browser extension or middlebox.
180
- - The `NetworkError.body` and `APIError.body` fields capture Keepa's response body. Keepa doesn't echo your key in error bodies, but if you log them to a customer-facing surface, sanity-check the contents first.
181
-
182
- ## Scripts
189
+ ```ts
190
+ import { isValidAsin, normalizeAsins } from 'keepa-api';
183
191
 
184
- ```bash
185
- npm test # vitest run — unit tests
186
- npm run test:watch # vitest watch
187
- npm run build # tsc → dist/
188
- npm run example # runs examples/basic.ts against real Keepa (needs .env)
189
- npm run clean # rm -rf dist
192
+ isValidAsin('B00MNV8E0C'); // true
193
+ normalizeAsins([' b00mnv8e0c ']); // ['B00MNV8E0C']
194
+ normalizeAsins(['B07XYZ']); // throws too short
190
195
  ```
191
196
 
192
- ## Roadmap
197
+ `products.list` / `products.retrieve` call `normalizeAsins` for you. The regex catches malformed ASINs but can't tell you whether Keepa has the record — use `isFoundProduct` for that.
193
198
 
194
- **Resources**
195
- - [ ] `Categories` resource (`fetchKeepaCategories`)
196
- - [ ] `Categories.search` (`searchKeepaCategories`)
197
- - [ ] `Bestsellers.retrieve` (`fetchKeepaBestSeller`)
199
+ ## Security note
198
200
 
199
- **Request layer**
200
- - [ ] `AbortSignal` / configurable timeout on `Products.list` (default ~30s) — currently a hung Keepa server keeps the request pending forever
201
- - [ ] Pass-through `init` (method, headers, signal) on `core/request` for resources that need POST or custom headers
202
- - [ ] Cap `asins.length` at Keepa's per-call limit (100) instead of letting Keepa silently truncate
203
- - [ ] Optional `retryOn429` (with backoff) — currently we throw `RateLimitError` immediately; consumers handle pacing in their own code
201
+ Keepa's API authenticates via `?key=...` query parameter (not a header), so:
204
202
 
205
- **Distribution**
206
- - [ ] CommonJS build alongside ESM
207
- - [ ] Publish to npm
203
+ - **Don't run this in a browser.** Your key would be visible in DevTools and to any browser extension or middlebox.
204
+ - Server-side access logs will record full URLs (with key). Configure log scrubbing if the layer isn't trusted.
205
+ - `APIError.body` and `NetworkError.message` are scrubbed of the key before being thrown.
208
206
 
209
207
  ## License
210
208
 
package/dist/index.cjs CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  ASIN_LENGTH: () => ASIN_LENGTH,
27
27
  ASIN_REGEX: () => ASIN_REGEX,
28
28
  AuthenticationError: () => AuthenticationError,
29
+ CsvType: () => CsvType,
29
30
  DEFAULT_DAYS: () => DEFAULT_DAYS,
30
31
  KEEPA_NO_DATA_SENTINEL: () => KEEPA_NO_DATA_SENTINEL,
31
32
  KeepaClient: () => KeepaClient,
@@ -43,6 +44,7 @@ __export(index_exports, {
43
44
  isFoundProduct: () => isFoundProduct,
44
45
  isValidAsin: () => isValidAsin,
45
46
  normalizeAsins: () => normalizeAsins,
47
+ parsePriceHistory: () => parsePriceHistory,
46
48
  resolveDomainId: () => resolveDomainId
47
49
  });
48
50
  module.exports = __toCommonJS(index_exports);
@@ -69,22 +71,25 @@ var APIError = class _APIError extends KeepaError {
69
71
  this.context = context;
70
72
  this.body = safeBody;
71
73
  }
72
- static async from(response, context) {
73
- const body = await response.text().catch(() => "");
74
- switch (response.status) {
74
+ static from(status, context, body, rateLimit = null) {
75
+ switch (status) {
75
76
  case 429:
76
- return new RateLimitError(context, body);
77
+ return new RateLimitError(context, body, rateLimit);
77
78
  case 401:
78
79
  return new AuthenticationError(context, body);
79
80
  default:
80
- return new _APIError(response.status, context, body);
81
+ return new _APIError(status, context, body);
81
82
  }
82
83
  }
83
84
  };
84
85
  var RateLimitError = class extends APIError {
85
- constructor(context, body) {
86
+ /** Bucket snapshot at the moment the 429 was returned. Null if Keepa's body
87
+ * didn't carry the usual rate-limit fields. */
88
+ rateLimit;
89
+ constructor(context, body, rateLimit = null) {
86
90
  super(429, context, body, "Keepa rate limit exceeded, please wait or upgrade plan");
87
91
  this.name = "RateLimitError";
92
+ this.rateLimit = rateLimit;
88
93
  }
89
94
  };
90
95
  var AuthenticationError = class extends APIError {
@@ -110,6 +115,20 @@ var NetworkError = class extends KeepaError {
110
115
  }
111
116
  };
112
117
 
118
+ // src/core/rate-limit.ts
119
+ function extractRateLimit(body) {
120
+ if (!body || typeof body !== "object") return null;
121
+ const b = body;
122
+ if (typeof b.tokensLeft !== "number") return null;
123
+ return {
124
+ tokensLeft: b.tokensLeft,
125
+ refillIn: typeof b.refillIn === "number" ? b.refillIn : 0,
126
+ refillRate: typeof b.refillRate === "number" ? b.refillRate : 0,
127
+ tokenFlowReduction: typeof b.tokenFlowReduction === "number" ? b.tokenFlowReduction : 0,
128
+ receivedAt: /* @__PURE__ */ new Date()
129
+ };
130
+ }
131
+
113
132
  // src/core/request.ts
114
133
  function buildUrl(baseURL, path, query) {
115
134
  if (!query) return `${baseURL}${path}`;
@@ -133,8 +152,19 @@ async function request(config, args) {
133
152
  } catch (cause) {
134
153
  throw new NetworkError(args.context, cause);
135
154
  }
136
- if (!res.ok) throw await APIError.from(res, args.context);
137
- return await res.json();
155
+ const text = await res.text().catch(() => "");
156
+ const body = safeJsonParse(text);
157
+ const rl = extractRateLimit(body);
158
+ if (rl) config.onRateLimit?.(rl);
159
+ if (!res.ok) throw APIError.from(res.status, args.context, text, rl);
160
+ return body;
161
+ }
162
+ function safeJsonParse(text) {
163
+ try {
164
+ return JSON.parse(text);
165
+ } catch {
166
+ return null;
167
+ }
138
168
  }
139
169
 
140
170
  // src/core/resource.ts
@@ -194,6 +224,45 @@ var PRODUCT_LIST_CONTEXT = "products.list";
194
224
  var DEFAULT_DAYS = 1;
195
225
  var AMAZON_IMAGE_BASE = "https://m.media-amazon.com/images/I";
196
226
  var KEEPA_NO_DATA_SENTINEL = -1;
227
+ var CsvType = {
228
+ AMAZON: 0,
229
+ NEW: 1,
230
+ USED: 2,
231
+ SALES: 3,
232
+ LISTPRICE: 4,
233
+ COLLECTIBLE: 5,
234
+ REFURBISHED: 6,
235
+ NEW_FBM_SHIPPING: 7,
236
+ LIGHTNING_DEAL: 8,
237
+ WAREHOUSE: 9,
238
+ NEW_FBA: 10,
239
+ COUNT_NEW: 11,
240
+ COUNT_USED: 12,
241
+ COUNT_REFURBISHED: 13,
242
+ COUNT_COLLECTIBLE: 14,
243
+ EXTRA_INFO_UPDATES: 15,
244
+ RATING: 16,
245
+ COUNT_REVIEWS: 17,
246
+ BUY_BOX_SHIPPING: 18,
247
+ USED_NEW_SHIPPING: 19,
248
+ USED_VERY_GOOD_SHIPPING: 20,
249
+ USED_GOOD_SHIPPING: 21,
250
+ USED_ACCEPTABLE_SHIPPING: 22,
251
+ COLLECTIBLE_NEW_SHIPPING: 23,
252
+ COLLECTIBLE_VERY_GOOD_SHIPPING: 24,
253
+ COLLECTIBLE_GOOD_SHIPPING: 25,
254
+ COLLECTIBLE_ACCEPTABLE_SHIPPING: 26,
255
+ REFURBISHED_SHIPPING: 27,
256
+ EBAY_NEW_SHIPPING: 28,
257
+ EBAY_USED_SHIPPING: 29,
258
+ TRADE_IN: 30,
259
+ RENTAL: 31,
260
+ BUY_BOX_USED_SHIPPING: 32,
261
+ PRIME_EXCL: 33,
262
+ COUNT_NEW_FBA: 34,
263
+ COUNT_NEW_FBM: 35
264
+ };
265
+ var KEEPA_EPOCH_UNIX_MS = 129384e7;
197
266
  var VALID_IMAGE_FILENAME = /^[A-Za-z0-9._-]+\.(jpg|jpeg|png|webp)$/i;
198
267
 
199
268
  // src/resources/products/error.ts
@@ -208,6 +277,9 @@ var ProductNotFoundError = class extends KeepaError {
208
277
 
209
278
  // src/resources/products/product.util.ts
210
279
  function toKeepaProduct(raw) {
280
+ const amazon = parsePriceHistory(raw.csv?.[CsvType.AMAZON]);
281
+ const new_ = parsePriceHistory(raw.csv?.[CsvType.NEW]);
282
+ const list = parsePriceHistory(raw.csv?.[CsvType.LISTPRICE]);
211
283
  return {
212
284
  asin: raw.asin,
213
285
  title: raw.title,
@@ -219,9 +291,34 @@ function toKeepaProduct(raw) {
219
291
  variations: raw.variations,
220
292
  features: raw.features,
221
293
  images: rawImagesToUrls(raw.images),
222
- bsr: extractBsr(raw.salesRanks, raw.rootCategory)
294
+ bsr: extractBsr(raw.salesRanks, raw.rootCategory),
295
+ amazonPrice: amazon.at(-1)?.price ?? null,
296
+ newPrice: new_.at(-1)?.price ?? null,
297
+ listPrice: list.at(-1)?.price ?? null,
298
+ history: {
299
+ price: { amazon, new: new_, list }
300
+ }
223
301
  };
224
302
  }
303
+ function pairKeepaSeries(series) {
304
+ if (!series) return [];
305
+ const points = [];
306
+ for (let i = 0; i + 1 < series.length; i += 2) {
307
+ const value = series[i + 1];
308
+ if (value === KEEPA_NO_DATA_SENTINEL) continue;
309
+ points.push({ timestamp: series[i], value });
310
+ }
311
+ return points;
312
+ }
313
+ function keepaMinutesToDate(minutes) {
314
+ return new Date(minutes * 6e4 + KEEPA_EPOCH_UNIX_MS);
315
+ }
316
+ function parsePriceHistory(series) {
317
+ return pairKeepaSeries(series).map(({ timestamp, value }) => ({
318
+ timestamp: keepaMinutesToDate(timestamp),
319
+ price: value / 100
320
+ }));
321
+ }
225
322
  function rawImagesToUrls(images) {
226
323
  if (!images || images.length === 0) return [];
227
324
  return images.filter((img) => typeof img.l === "string" && VALID_IMAGE_FILENAME.test(img.l)).map((img) => `${AMAZON_IMAGE_BASE}/${img.l}`);
@@ -231,14 +328,7 @@ function isFoundProduct(product) {
231
328
  }
232
329
  function extractBsr(salesRanks, rootCategory) {
233
330
  if (!salesRanks || rootCategory === void 0) return null;
234
- const ranks = salesRanks[String(rootCategory)];
235
- if (!ranks || ranks.length < 2) return null;
236
- const start = ranks.length % 2 === 0 ? ranks.length - 1 : ranks.length - 2;
237
- for (let i = start; i >= 1; i -= 2) {
238
- const rank = ranks[i];
239
- if (rank !== void 0 && rank !== KEEPA_NO_DATA_SENTINEL) return rank;
240
- }
241
- return null;
331
+ return pairKeepaSeries(salesRanks[String(rootCategory)]).at(-1)?.value ?? null;
242
332
  }
243
333
 
244
334
  // src/resources/products/products.ts
@@ -257,14 +347,14 @@ var Products = class extends APIResource {
257
347
  query: {
258
348
  domain,
259
349
  asin: asins,
260
- days: params.days ?? DEFAULT_DAYS
350
+ days: params.days ?? DEFAULT_DAYS,
351
+ history: params.history ? 1 : 0
261
352
  },
262
353
  context: PRODUCT_LIST_CONTEXT
263
354
  });
264
355
  return (data.products ?? []).map(toKeepaProduct);
265
356
  }
266
- /** Fetch a single product by ASIN. Throws `ProductNotFoundError` when Keepa
267
- * has no record for the ASIN (no product returned, or a stub with no title). */
357
+ /** Throws `ProductNotFoundError` when Keepa returns no product or a stub. */
268
358
  async retrieve(params) {
269
359
  const { asin, ...rest } = params;
270
360
  const [product] = await this.list({ ...rest, asins: [asin] });
@@ -282,6 +372,9 @@ var KeepaClient = class {
282
372
  baseURL;
283
373
  fetch;
284
374
  products;
375
+ /** Latest rate-limit snapshot from Keepa, updated after every response.
376
+ * Null until the first request completes. */
377
+ rateLimit = null;
285
378
  constructor(options = {}) {
286
379
  const envApiKey = typeof process !== "undefined" ? process.env?.KEEPA_API_KEY : void 0;
287
380
  const apiKey = options.apiKey || envApiKey;
@@ -295,12 +388,14 @@ var KeepaClient = class {
295
388
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
296
389
  this.products = new Products(this);
297
390
  }
298
- /** Internal: used by APIResource subclasses to perform a request. */
299
391
  _request(args) {
300
392
  const config = {
301
393
  apiKey: this.apiKey,
302
394
  baseURL: this.baseURL,
303
- fetch: this.fetch
395
+ fetch: this.fetch,
396
+ onRateLimit: (info) => {
397
+ this.rateLimit = info;
398
+ }
304
399
  };
305
400
  return request(config, args);
306
401
  }
@@ -316,6 +411,7 @@ var VERSION = "0.1.0";
316
411
  ASIN_LENGTH,
317
412
  ASIN_REGEX,
318
413
  AuthenticationError,
414
+ CsvType,
319
415
  DEFAULT_DAYS,
320
416
  KEEPA_NO_DATA_SENTINEL,
321
417
  KeepaClient,
@@ -332,6 +428,7 @@ var VERSION = "0.1.0";
332
428
  isFoundProduct,
333
429
  isValidAsin,
334
430
  normalizeAsins,
431
+ parsePriceHistory,
335
432
  resolveDomainId
336
433
  });
337
434
  //# sourceMappingURL=index.cjs.map