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 +123 -125
- package/dist/index.cjs +119 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -42
- package/dist/index.d.ts +109 -42
- package/dist/index.js +117 -22
- 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,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` | `
|
|
82
|
-
| `days` | `number` | `1` | Days of
|
|
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
|
-
|
|
60
|
+
### `KeepaProduct`
|
|
85
61
|
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
`title`, `description`, `parentAsin`, `categoryTree`, `salesRanks`, `variations`, `features` pass through from Keepa unchanged.
|
|
92
76
|
|
|
93
|
-
|
|
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
|
|
82
|
+
const found = products.filter(isFoundProduct);
|
|
83
|
+
```
|
|
99
84
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
105
|
+
import { CsvType, parsePriceHistory } from 'keepa-api';
|
|
117
106
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
isValidAsin('B07XYZ'); // false (too short)
|
|
107
|
+
const usedPrices = parsePriceHistory(rawProduct.csv?.[CsvType.USED]);
|
|
108
|
+
```
|
|
121
109
|
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
Codes are ISO 3166-1 alpha-2. Domain 7 is reserved (formerly Amazon China, retired by Keepa).
|
|
129
131
|
|
|
130
|
-
|
|
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
|
-
|
|
137
|
+
await keepa.products.list({ asins });
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
The SDK does **not** auto-retry on 429. Handling that is your call.
|
|
150
162
|
|
|
151
|
-
|
|
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
|
|
177
|
+
await keepa.products.list({ asins });
|
|
164
178
|
} catch (err) {
|
|
165
|
-
if (err instanceof RateLimitError)
|
|
166
|
-
else if (err instanceof AuthenticationError) /* 401 —
|
|
167
|
-
else if (err instanceof APIError)
|
|
168
|
-
else if (err instanceof NetworkError)
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
- [ ] `Categories` resource (`fetchKeepaCategories`)
|
|
196
|
-
- [ ] `Categories.search` (`searchKeepaCategories`)
|
|
197
|
-
- [ ] `Bestsellers.retrieve` (`fetchKeepaBestSeller`)
|
|
199
|
+
## Security note
|
|
198
200
|
|
|
199
|
-
|
|
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
|
-
**
|
|
206
|
-
-
|
|
207
|
-
-
|
|
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
|
|
73
|
-
|
|
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(
|
|
81
|
+
return new _APIError(status, context, body);
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
};
|
|
84
85
|
var RateLimitError = class extends APIError {
|
|
85
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|