keepa-api 0.3.1 → 0.3.3

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,166 +42,170 @@ 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 to scope csv data when `history: true`. Must be a positive integer (validated pre-flight). |
83
- | `history` | `boolean` | `false` | When `true`, requests Keepa's csv history matrix and parses it into `product.history.price.{amazon,list}` (and the scalar `price`/`listPrice` counterparts) on each returned product. When `false`, those fields are empty/null. Affects token cost — leave off when you don't need it. |
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
+ | `stats` | `boolean` | `false` | Populates the `stats` namespace (buy-box saving basis, etc.). Costs extra tokens. |
56
+
57
+ ### `keepa.products.retrieve(params)` → `Promise<KeepaProduct>`
84
58
 
85
- The SDK maps Keepa's raw wire shape into a friendlier `KeepaProduct`:
59
+ 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).
86
60
 
87
- - `images: string[]` — full image URLs (region-neutral CDN). Replaces Keepa's awkward `imagesCSV` string.
88
- - `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.
89
- - `price: number | null` / `listPrice: number | null` — latest Amazon price and list price in the marketplace's **major unit** (dollars for US, pounds for GB, yen for JP, etc.). Derived from the last entry of `history.price.amazon` / `history.price.list`. `null` when `history: false` was used or Keepa has no data.
90
- - `history.price.amazon: PriceHistoryEntry[]` / `history.price.list: PriceHistoryEntry[]` — full parsed price series. Empty `[]` when `history: false` was used. Prices are in the marketplace's major unit (same as the scalar fields).
61
+ ### `KeepaProduct`
91
62
 
92
- 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.
63
+ The SDK reshapes Keepa's wire format into something usable:
93
64
 
94
- **Stub records:** Keepa returns one record per requested ASIN even when it has no data — these stubs have `title === null`. Filter with `isFoundProduct` (below).
65
+ | Field | Type | Notes |
66
+ |-------|------|-------|
67
+ | `images` | `string[]` | Full image URLs (region-neutral CDN). |
68
+ | `bsr` | `number \| null` | Latest non-sentinel BSR for `rootCategory`. |
69
+ | `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`. |
70
+ | `newPrice` | `number \| null` | Latest lowest-3rd-party-new offer price (csv[1]). Distinct from `amazonPrice` — Amazon isn't always the cheapest new seller. |
71
+ | `listPrice` | `number \| null` | Latest list price / MSRP (csv[4]). |
72
+ | `history.price.amazon` | `PriceHistoryEntry[]` | Amazon's own price over time. Empty without `history: true`. |
73
+ | `history.price.new` | `PriceHistoryEntry[]` | Lowest-3rd-party-new price over time. |
74
+ | `history.price.list` | `PriceHistoryEntry[]` | List price (MSRP) over time. |
75
+ | `stats.buyBoxSavingBasis` | `number \| null` | Buy box strikethrough reference price in the marketplace's major unit. Null without `stats: true` or when Keepa has no saving-basis data. |
76
+ | `stats.buyBoxSavingBasisType` | `SavingBasisType \| null` | Reference type for the strikethrough — `SavingBasisType.LIST_PRICE` or `SavingBasisType.WAS_PRICE`. Null when unavailable. |
95
77
 
96
- ### Price and price history
78
+ `title`, `description`, `parentAsin`, `categoryTree`, `salesRanks`, `variations`, `features` pass through from Keepa unchanged.
97
79
 
98
- Every `KeepaProduct` has the price fieldswhat changes with the `history` flag is whether they're populated:
80
+ **Stub records:** Keepa returns one entry per requested ASIN even for unknown ASINs those stubs have `title === null`. Filter them out:
99
81
 
100
82
  ```ts
101
- // Default: history fields exist but are empty/null. Cheapest call.
102
- const [product] = await keepa.products.list({ asins: ['B00MNV8E0C'] });
103
- product.price; // null
104
- product.history.price.amazon; // []
83
+ import { isFoundProduct } from 'keepa-api';
84
+
85
+ const found = products.filter(isFoundProduct);
86
+ ```
105
87
 
106
- // With history: arrays filled, scalar fields derive from the latest entry.
107
- const [detailed] = await keepa.products.list({
88
+ ### Price history
89
+
90
+ ```ts
91
+ const [product] = await keepa.products.list({
108
92
  asins: ['B00MNV8E0C'],
109
93
  history: true,
110
94
  days: 30,
111
95
  });
112
- detailed.price; // 18.99 — latest Amazon price (USD dollars)
113
- detailed.listPrice; // 29.99 — latest list price (USD dollars)
114
- detailed.history.price.amazon; // PriceHistoryEntry[] — Amazon's own price over time (csv[0])
115
- detailed.history.price.list; // PriceHistoryEntry[] — list price / MSRP over time (csv[4])
116
- ```
117
96
 
118
- Each `PriceHistoryEntry` is `{ timestamp: Date, price: number }` where `price` is in the marketplace's major unit (dollars for `'US'`, pounds for `'GB'`, yen for `'JP'`, etc. the consumer chose the marketplace, so the currency is known). Keepa's `-1` "no data captured" sentinel entries are filtered out, so iterating the arrays only yields real price points.
97
+ product.amazonPrice; // 18.99 (USD)Amazon's own latest
98
+ product.newPrice; // 16.99 — cheapest 3rd-party new
99
+ product.listPrice; // 29.99 — MSRP
100
+ product.history.price.amazon[0]; // { timestamp: Date, price: 19.49 }
101
+ ```
119
102
 
120
- If you need a csv type other than `CsvType.AMAZON` or `CsvType.LISTPRICE` (e.g. `CsvType.NEW`, `CsvType.USED`, `CsvType.REFURBISHED`, `CsvType.RATING`, …) — pull the row off the raw Keepa response and pass it through `parsePriceHistory`. The full 36-value mapping is exported as `CsvType` (matches Keepa's own enum names exactly).
103
+ 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.
121
104
 
122
- ### Helpers
105
+ For csv types beyond Amazon / list price (`NEW`, `USED`, `REFURBISHED`, `RATING`, …), pull the row off the raw response and parse it:
123
106
 
124
107
  ```ts
125
- import { isFoundProduct } from 'keepa-api';
108
+ import { CsvType, parsePriceHistory } from 'keepa-api';
126
109
 
127
- const real = products.filter(isFoundProduct);
128
-
129
- for (const product of real) {
130
- console.log(product.bsr); // already populated by the SDK
131
- console.log(product.images[0]); // already populated by the SDK
132
- }
110
+ const usedPrices = parsePriceHistory(rawProduct.csv?.[CsvType.USED]);
133
111
  ```
134
112
 
135
- | Helper | Signature | Returns |
136
- |--------|-----------|---------|
137
- | `isFoundProduct(product)` | `(product: KeepaProduct) => boolean` | `true` only if Keepa returned real data (stubs have `title === null`). |
138
- | `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. |
139
- | `parsePriceHistory(series)` | `(series: number[] \| undefined) => PriceHistoryEntry[]` | Parse one row of Keepa's csv matrix (e.g. `csv[CsvType.AMAZON]`, `csv[CsvType.LISTPRICE]`) into `{ timestamp, price }` entries — `price` in the marketplace's major unit. `-1` sentinels filtered out. Used internally to fill `history.price.*`; exported so callers can parse other csv types via `CsvType`. |
113
+ `CsvType` covers all 36 of Keepa's csv series names verbatim.
140
114
 
141
- ### ASIN validation
115
+ ## Marketplaces
142
116
 
143
117
  ```ts
144
- import { ASIN_REGEX, ASIN_LENGTH, isValidAsin, normalizeAsins } from 'keepa-api';
118
+ import { resolveDomainId } from 'keepa-api';
145
119
 
146
- isValidAsin('B00MNV8E0C'); // true
147
- isValidAsin('b00mnv8e0c'); // false (lowercase — use normalizeAsins to coerce)
148
- isValidAsin('B07XYZ'); // false (too short)
149
-
150
- normalizeAsins([' b00mnv8e0c ']); // ['B00MNV8E0C']
151
- normalizeAsins(['B07XYZ']); // throws: Invalid ASIN(s): B07XYZ. ...
120
+ resolveDomainId('gb'); // 2 (case-insensitive)
121
+ resolveDomainId(undefined); // 1 (defaults to US)
152
122
  ```
153
123
 
154
- `Products.list` calls `normalizeAsins` for you, so you don't need to pre-validate unless you're doing form-level checking.
124
+ | Code | Domain | Code | Domain |
125
+ |------|--------|------|--------|
126
+ | US | 1 | IT | 8 |
127
+ | GB | 2 | ES | 9 |
128
+ | DE | 3 | IN | 10 |
129
+ | FR | 4 | MX | 11 |
130
+ | JP | 5 | BR | 12 |
131
+ | CA | 6 | | |
155
132
 
156
- **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.
133
+ Codes are ISO 3166-1 alpha-2. Domain 7 is reserved (formerly Amazon China, retired by Keepa).
157
134
 
158
- ### Marketplaces
135
+ ## Rate limits
136
+
137
+ The client tracks Keepa's token bucket on every response — success and 429 alike — and exposes the latest snapshot:
159
138
 
160
139
  ```ts
161
- import { MARKETPLACE_DOMAINS, resolveDomainId } from 'keepa-api';
140
+ await keepa.products.list({ asins });
162
141
 
163
- MARKETPLACE_DOMAINS.US; // 1
164
- resolveDomainId('gb'); // 2 (case-insensitive)
165
- resolveDomainId(undefined); // 1 (defaults to US)
142
+ if (keepa.rateLimit && keepa.rateLimit.tokensLeft < 50) {
143
+ await sleep(keepa.rateLimit.refillIn); // ms until next token refills
144
+ }
145
+ ```
146
+
147
+ On a 429, the snapshot is also attached to the thrown error:
148
+
149
+ ```ts
150
+ import { RateLimitError } from 'keepa-api';
151
+
152
+ try {
153
+ await keepa.products.list({ asins: bigBatch });
154
+ } catch (err) {
155
+ if (err instanceof RateLimitError && err.rateLimit) {
156
+ await sleep(err.rateLimit.refillIn);
157
+ // retry…
158
+ }
159
+ }
166
160
  ```
167
161
 
168
- | Code | Domain ID | Code | Domain ID |
169
- |------|-----------|------|-----------|
170
- | US | 1 | IT | 8 |
171
- | GB | 2 | ES | 9 |
172
- | DE | 3 | IN | 10 |
173
- | FR | 4 | MX | 11 |
174
- | JP | 5 | BR | 12 |
175
- | CA | 6 | | |
162
+ `RateLimitInfo`: `tokensLeft`, `refillIn` (ms), `refillRate` (tokens/min), `tokenFlowReduction`, `receivedAt`.
176
163
 
177
- ### Errors
164
+ The SDK does **not** auto-retry on 429. Handling that is your call.
178
165
 
179
- All errors thrown by API calls extend `KeepaError`. Catch the specific subclasses for status-aware handling:
166
+ ## Errors
167
+
168
+ All errors extend `KeepaError`:
180
169
 
181
170
  ```ts
182
171
  import {
172
+ KeepaError,
173
+ APIError,
183
174
  RateLimitError,
184
175
  AuthenticationError,
185
- APIError,
186
176
  NetworkError,
187
- KeepaError,
188
177
  } from 'keepa-api';
189
178
 
190
179
  try {
191
- await keepa.products.list({ asins: ['B00MNV8E0C'] });
180
+ await keepa.products.list({ asins });
192
181
  } catch (err) {
193
- if (err instanceof RateLimitError) /* 429 */;
194
- else if (err instanceof AuthenticationError) /* 401 — bad API key */;
195
- else if (err instanceof APIError) /* 4xx/5xx — err.status, err.body */;
196
- else if (err instanceof NetworkError) /* DNS/ECONNREFUSED/abort — err.cause has the original */;
197
- else if (err instanceof KeepaError) /* something else from this SDK */;
182
+ if (err instanceof RateLimitError) /* 429 — err.rateLimit */;
183
+ else if (err instanceof AuthenticationError) /* 401 — invalid API key */;
184
+ else if (err instanceof APIError) /* 4xx/5xx — err.status, err.body */;
185
+ else if (err instanceof NetworkError) /* transport — err.cause */;
198
186
  else throw err;
199
187
  }
200
188
  ```
201
189
 
202
- ### API key handling
203
-
204
- Keepa's REST API requires the key as a `?key=...` query parameter — that's their contract, not a choice we made. Practical implications:
190
+ ## ASIN utilities
205
191
 
206
- - **Server-side proxy / access logs** will record full URLs (key included). Configure log scrubbing if you can't trust the layer.
207
- - **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.
208
- - 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.
209
-
210
- ## Scripts
192
+ ```ts
193
+ import { isValidAsin, normalizeAsins } from 'keepa-api';
211
194
 
212
- ```bash
213
- npm test # vitest run — unit tests
214
- npm run test:watch # vitest watch
215
- npm run build # tsc → dist/
216
- npm run example # runs examples/basic.ts against real Keepa (needs .env)
217
- npm run clean # rm -rf dist
195
+ isValidAsin('B00MNV8E0C'); // true
196
+ normalizeAsins([' b00mnv8e0c ']); // ['B00MNV8E0C']
197
+ normalizeAsins(['B07XYZ']); // throws too short
218
198
  ```
219
199
 
220
- ## Roadmap
200
+ `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.
221
201
 
222
- **Resources**
223
- - [ ] `Categories` resource (`fetchKeepaCategories`)
224
- - [ ] `Categories.search` (`searchKeepaCategories`)
225
- - [ ] `Bestsellers.retrieve` (`fetchKeepaBestSeller`)
202
+ ## Security note
226
203
 
227
- **Request layer**
228
- - [ ] `AbortSignal` / configurable timeout on `Products.list` (default ~30s) — currently a hung Keepa server keeps the request pending forever
229
- - [ ] Pass-through `init` (method, headers, signal) on `core/request` for resources that need POST or custom headers
230
- - [ ] Cap `asins.length` at Keepa's per-call limit (100) instead of letting Keepa silently truncate
231
- - [ ] Optional `retryOn429` (with backoff) — currently we throw `RateLimitError` immediately; consumers handle pacing in their own code
204
+ Keepa's API authenticates via `?key=...` query parameter (not a header), so:
232
205
 
233
- **Distribution**
234
- - [ ] CommonJS build alongside ESM
235
- - [ ] Publish to npm
206
+ - **Don't run this in a browser.** Your key would be visible in DevTools and to any browser extension or middlebox.
207
+ - Server-side access logs will record full URLs (with key). Configure log scrubbing if the layer isn't trusted.
208
+ - `APIError.body` and `NetworkError.message` are scrubbed of the key before being thrown.
236
209
 
237
210
  ## License
238
211
 
package/dist/index.cjs CHANGED
@@ -38,13 +38,16 @@ __export(index_exports, {
38
38
  ProductNotFoundError: () => ProductNotFoundError,
39
39
  Products: () => Products,
40
40
  RateLimitError: () => RateLimitError,
41
+ SavingBasisType: () => SavingBasisType,
41
42
  VERSION: () => VERSION,
42
43
  default: () => KeepaClient,
43
44
  extractBsr: () => extractBsr,
44
45
  isFoundProduct: () => isFoundProduct,
45
46
  isValidAsin: () => isValidAsin,
46
47
  normalizeAsins: () => normalizeAsins,
48
+ parsePrice: () => parsePrice,
47
49
  parsePriceHistory: () => parsePriceHistory,
50
+ parseSavingBasisType: () => parseSavingBasisType,
48
51
  resolveDomainId: () => resolveDomainId
49
52
  });
50
53
  module.exports = __toCommonJS(index_exports);
@@ -71,22 +74,25 @@ var APIError = class _APIError extends KeepaError {
71
74
  this.context = context;
72
75
  this.body = safeBody;
73
76
  }
74
- static async from(response, context) {
75
- const body = await response.text().catch(() => "");
76
- switch (response.status) {
77
+ static from(status, context, body, rateLimit = null) {
78
+ switch (status) {
77
79
  case 429:
78
- return new RateLimitError(context, body);
80
+ return new RateLimitError(context, body, rateLimit);
79
81
  case 401:
80
82
  return new AuthenticationError(context, body);
81
83
  default:
82
- return new _APIError(response.status, context, body);
84
+ return new _APIError(status, context, body);
83
85
  }
84
86
  }
85
87
  };
86
88
  var RateLimitError = class extends APIError {
87
- constructor(context, body) {
89
+ /** Bucket snapshot at the moment the 429 was returned. Null if Keepa's body
90
+ * didn't carry the usual rate-limit fields. */
91
+ rateLimit;
92
+ constructor(context, body, rateLimit = null) {
88
93
  super(429, context, body, "Keepa rate limit exceeded, please wait or upgrade plan");
89
94
  this.name = "RateLimitError";
95
+ this.rateLimit = rateLimit;
90
96
  }
91
97
  };
92
98
  var AuthenticationError = class extends APIError {
@@ -112,6 +118,20 @@ var NetworkError = class extends KeepaError {
112
118
  }
113
119
  };
114
120
 
121
+ // src/core/rate-limit.ts
122
+ function extractRateLimit(body) {
123
+ if (!body || typeof body !== "object") return null;
124
+ const b = body;
125
+ if (typeof b.tokensLeft !== "number") return null;
126
+ return {
127
+ tokensLeft: b.tokensLeft,
128
+ refillIn: typeof b.refillIn === "number" ? b.refillIn : 0,
129
+ refillRate: typeof b.refillRate === "number" ? b.refillRate : 0,
130
+ tokenFlowReduction: typeof b.tokenFlowReduction === "number" ? b.tokenFlowReduction : 0,
131
+ receivedAt: /* @__PURE__ */ new Date()
132
+ };
133
+ }
134
+
115
135
  // src/core/request.ts
116
136
  function buildUrl(baseURL, path, query) {
117
137
  if (!query) return `${baseURL}${path}`;
@@ -135,8 +155,19 @@ async function request(config, args) {
135
155
  } catch (cause) {
136
156
  throw new NetworkError(args.context, cause);
137
157
  }
138
- if (!res.ok) throw await APIError.from(res, args.context);
139
- return await res.json();
158
+ const text = await res.text().catch(() => "");
159
+ const body = safeJsonParse(text);
160
+ const rl = extractRateLimit(body);
161
+ if (rl) config.onRateLimit?.(rl);
162
+ if (!res.ok) throw APIError.from(res.status, args.context, text, rl);
163
+ return body;
164
+ }
165
+ function safeJsonParse(text) {
166
+ try {
167
+ return JSON.parse(text);
168
+ } catch {
169
+ return null;
170
+ }
140
171
  }
141
172
 
142
173
  // src/core/resource.ts
@@ -236,6 +267,10 @@ var CsvType = {
236
267
  };
237
268
  var KEEPA_EPOCH_UNIX_MS = 129384e7;
238
269
  var VALID_IMAGE_FILENAME = /^[A-Za-z0-9._-]+\.(jpg|jpeg|png|webp)$/i;
270
+ var SavingBasisType = {
271
+ LIST_PRICE: 0,
272
+ WAS_PRICE: 1
273
+ };
239
274
 
240
275
  // src/resources/products/error.ts
241
276
  var ProductNotFoundError = class extends KeepaError {
@@ -250,6 +285,7 @@ var ProductNotFoundError = class extends KeepaError {
250
285
  // src/resources/products/product.util.ts
251
286
  function toKeepaProduct(raw) {
252
287
  const amazon = parsePriceHistory(raw.csv?.[CsvType.AMAZON]);
288
+ const new_ = parsePriceHistory(raw.csv?.[CsvType.NEW]);
253
289
  const list = parsePriceHistory(raw.csv?.[CsvType.LISTPRICE]);
254
290
  return {
255
291
  asin: raw.asin,
@@ -263,13 +299,26 @@ function toKeepaProduct(raw) {
263
299
  features: raw.features,
264
300
  images: rawImagesToUrls(raw.images),
265
301
  bsr: extractBsr(raw.salesRanks, raw.rootCategory),
266
- price: amazon.at(-1)?.price ?? null,
302
+ amazonPrice: amazon.at(-1)?.price ?? null,
303
+ newPrice: new_.at(-1)?.price ?? null,
267
304
  listPrice: list.at(-1)?.price ?? null,
268
305
  history: {
269
- price: { amazon, list }
306
+ price: { amazon, new: new_, list }
307
+ },
308
+ stats: {
309
+ buyBoxSavingBasis: parsePrice(raw.stats?.buyBoxSavingBasis),
310
+ buyBoxSavingBasisType: parseSavingBasisType(raw.stats?.buyBoxSavingBasisType)
270
311
  }
271
312
  };
272
313
  }
314
+ function parsePrice(value) {
315
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
316
+ if (value === KEEPA_NO_DATA_SENTINEL) return null;
317
+ return value / 100;
318
+ }
319
+ function parseSavingBasisType(value) {
320
+ return value === SavingBasisType.LIST_PRICE || value === SavingBasisType.WAS_PRICE ? value : null;
321
+ }
273
322
  function pairKeepaSeries(series) {
274
323
  if (!series) return [];
275
324
  const points = [];
@@ -284,10 +333,10 @@ function keepaMinutesToDate(minutes) {
284
333
  return new Date(minutes * 6e4 + KEEPA_EPOCH_UNIX_MS);
285
334
  }
286
335
  function parsePriceHistory(series) {
287
- return pairKeepaSeries(series).map(({ timestamp, value }) => ({
288
- timestamp: keepaMinutesToDate(timestamp),
289
- price: value / 100
290
- }));
336
+ return pairKeepaSeries(series).flatMap(({ timestamp, value }) => {
337
+ const price = parsePrice(value);
338
+ return price === null ? [] : [{ timestamp: keepaMinutesToDate(timestamp), price }];
339
+ });
291
340
  }
292
341
  function rawImagesToUrls(images) {
293
342
  if (!images || images.length === 0) return [];
@@ -318,7 +367,10 @@ var Products = class extends APIResource {
318
367
  domain,
319
368
  asin: asins,
320
369
  days: params.days ?? DEFAULT_DAYS,
321
- history: params.history ? 1 : 0
370
+ history: params.history ? 1 : 0,
371
+ // Keepa's `stats` parameter is the number of days to compute stats over;
372
+ // we pass `days` when the flag is on, 0 to disable.
373
+ stats: params.stats ? params.days ?? DEFAULT_DAYS : 0
322
374
  },
323
375
  context: PRODUCT_LIST_CONTEXT
324
376
  });
@@ -342,6 +394,9 @@ var KeepaClient = class {
342
394
  baseURL;
343
395
  fetch;
344
396
  products;
397
+ /** Latest rate-limit snapshot from Keepa, updated after every response.
398
+ * Null until the first request completes. */
399
+ rateLimit = null;
345
400
  constructor(options = {}) {
346
401
  const envApiKey = typeof process !== "undefined" ? process.env?.KEEPA_API_KEY : void 0;
347
402
  const apiKey = options.apiKey || envApiKey;
@@ -359,7 +414,10 @@ var KeepaClient = class {
359
414
  const config = {
360
415
  apiKey: this.apiKey,
361
416
  baseURL: this.baseURL,
362
- fetch: this.fetch
417
+ fetch: this.fetch,
418
+ onRateLimit: (info) => {
419
+ this.rateLimit = info;
420
+ }
363
421
  };
364
422
  return request(config, args);
365
423
  }
@@ -387,12 +445,15 @@ var VERSION = "0.1.0";
387
445
  ProductNotFoundError,
388
446
  Products,
389
447
  RateLimitError,
448
+ SavingBasisType,
390
449
  VERSION,
391
450
  extractBsr,
392
451
  isFoundProduct,
393
452
  isValidAsin,
394
453
  normalizeAsins,
454
+ parsePrice,
395
455
  parsePriceHistory,
456
+ parseSavingBasisType,
396
457
  resolveDomainId
397
458
  });
398
459
  //# sourceMappingURL=index.cjs.map