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 +118 -145
- package/dist/index.cjs +77 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -56
- package/dist/index.d.ts +102 -56
- package/dist/index.js +74 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
|
|
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
|
-
|
|
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 CJS — works 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
|
|
57
|
-
asins: ['B00MNV8E0C'],
|
|
58
|
-
marketplace: 'US',
|
|
59
|
-
});
|
|
26
|
+
const [product] = await keepa.products.list({ asins: ['B00MNV8E0C'] });
|
|
60
27
|
|
|
61
|
-
console.log(
|
|
28
|
+
console.log(product?.title, product?.bsr, product?.images[0]);
|
|
62
29
|
```
|
|
63
30
|
|
|
64
|
-
|
|
31
|
+
`apiKey` falls back to `process.env.KEEPA_API_KEY` when omitted.
|
|
65
32
|
|
|
66
|
-
##
|
|
33
|
+
## Client
|
|
67
34
|
|
|
68
|
-
|
|
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` | `
|
|
82
|
-
| `days` | `number` | `1` | Days of
|
|
83
|
-
| `history` | `boolean` | `false` |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63
|
+
The SDK reshapes Keepa's wire format into something usable:
|
|
93
64
|
|
|
94
|
-
|
|
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
|
-
|
|
78
|
+
`title`, `description`, `parentAsin`, `categoryTree`, `salesRanks`, `variations`, `features` pass through from Keepa unchanged.
|
|
97
79
|
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
83
|
+
import { isFoundProduct } from 'keepa-api';
|
|
84
|
+
|
|
85
|
+
const found = products.filter(isFoundProduct);
|
|
86
|
+
```
|
|
105
87
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
108
|
+
import { CsvType, parsePriceHistory } from 'keepa-api';
|
|
126
109
|
|
|
127
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
## Marketplaces
|
|
142
116
|
|
|
143
117
|
```ts
|
|
144
|
-
import {
|
|
118
|
+
import { resolveDomainId } from 'keepa-api';
|
|
145
119
|
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
Codes are ISO 3166-1 alpha-2. Domain 7 is reserved (formerly Amazon China, retired by Keepa).
|
|
157
134
|
|
|
158
|
-
|
|
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
|
-
|
|
140
|
+
await keepa.products.list({ asins });
|
|
162
141
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
The SDK does **not** auto-retry on 429. Handling that is your call.
|
|
178
165
|
|
|
179
|
-
|
|
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
|
|
180
|
+
await keepa.products.list({ asins });
|
|
192
181
|
} catch (err) {
|
|
193
|
-
if (err instanceof RateLimitError)
|
|
194
|
-
else if (err instanceof AuthenticationError) /* 401 —
|
|
195
|
-
else if (err instanceof APIError)
|
|
196
|
-
else if (err instanceof NetworkError)
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
- [ ] `Categories` resource (`fetchKeepaCategories`)
|
|
224
|
-
- [ ] `Categories.search` (`searchKeepaCategories`)
|
|
225
|
-
- [ ] `Bestsellers.retrieve` (`fetchKeepaBestSeller`)
|
|
202
|
+
## Security note
|
|
226
203
|
|
|
227
|
-
|
|
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
|
-
**
|
|
234
|
-
-
|
|
235
|
-
-
|
|
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
|
|
75
|
-
|
|
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(
|
|
84
|
+
return new _APIError(status, context, body);
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
};
|
|
86
88
|
var RateLimitError = class extends APIError {
|
|
87
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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).
|
|
288
|
-
|
|
289
|
-
price:
|
|
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
|