tcgpriser 0.10.0 β†’ 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,7 @@ A typed Node.js / browser client for the [tcgpriser.se](https://tcgpriser.se) AP
14
14
  - ⚑ **Async/await** on every method, no callbacks
15
15
  - πŸ› οΈ **Full IntelliSense** for every method and response field
16
16
  - πŸ“¦ **Zero runtime dependencies**, built on the standard `fetch` API, ships as ESM and CJS
17
+ - ⏱️ **Timeouts and `AbortSignal`** on every call, with a sane default rather than none
17
18
  - πŸ”„ **Types stay in sync with the API**: `yarn generate:types` regenerates them from a live instance
18
19
 
19
20
  ## Installation
@@ -51,7 +52,8 @@ Public methods need no token. A handful of premium methods do, see [Authenticati
51
52
  ### Cards
52
53
 
53
54
  ```typescript
54
- await tcgpriser.cards.list({ search: 'pikachu', limit: 10 });
55
+ await tcgpriser.cards.list({ limit: 10 }); // newest first, no free-text search
56
+ await tcgpriser.cards.search({ search: 'pikachu' }); // premium
55
57
  await tcgpriser.cards.get('mega-evolution-ascended-heroes-fezandipiti-ex'); // id or technicalName
56
58
  await tcgpriser.cards.matches('fezandipiti-ex', { inStock: true });
57
59
  ```
@@ -61,7 +63,8 @@ await tcgpriser.cards.matches('fezandipiti-ex', { inStock: true });
61
63
  Booster boxes, ETBs, tins and the like. Single cards live under `cards`, not here.
62
64
 
63
65
  ```typescript
64
- await tcgpriser.products.list({ search: 'booster box' });
66
+ await tcgpriser.products.list({ limit: 10 }); // newest first, no free-text search
67
+ await tcgpriser.products.search({ search: 'booster box' }); // premium
65
68
  await tcgpriser.products.get('scarlet-violet-booster-pack');
66
69
  await tcgpriser.products.matches('scarlet-violet-booster-pack');
67
70
  ```
@@ -77,6 +80,31 @@ await tcgpriser.expansions.cards('eng-scarlet-violet-journey-together'); // cont
77
80
  await tcgpriser.expansions.sealedProducts('eng-scarlet-violet-journey-together'); // content only
78
81
  ```
79
82
 
83
+ ### Brands
84
+
85
+ The franchises and makers the catalogue carries β€” today just PokΓ©mon, but `cards`/`products`/
86
+ `expansions` all accept a `brand` filter (an `id` or `technicalName`) in preparation for more.
87
+
88
+ ```typescript
89
+ await tcgpriser.brands.list();
90
+ await tcgpriser.brands.get('pokemon');
91
+
92
+ // Scope any list to one brand. An unrecognized value is a 400, not a silently empty page.
93
+ await tcgpriser.cards.list({ brand: 'pokemon' });
94
+ await tcgpriser.expansions.list({ brand: 'pokemon' });
95
+
96
+ // Disambiguate a technicalName two brands might both use, on a single-item lookup.
97
+ await tcgpriser.products.get('booster-box', { brand: 'pokemon' });
98
+ ```
99
+
100
+ `productLine` narrows further, to what kind of catalogue item something is β€” `'tcg'` for every
101
+ card and sealed product today, plus `'accessory'` / `'collectible'` / `'boardGame'` /
102
+ `'videoGame'` / `'other'` for non-TCG items as the catalogue grows to carry them:
103
+
104
+ ```typescript
105
+ await tcgpriser.cards.list({ productLine: 'tcg' });
106
+ ```
107
+
80
108
  ### Pricing
81
109
 
82
110
  `list()`/`get()`/`expansions.cards()`/`expansions.sealedProducts()` all return catalog content
@@ -90,7 +118,7 @@ await tcgpriser.cards.pricing('fezandipiti-ex'); // id or technicalName
90
118
  await tcgpriser.products.pricing('scarlet-violet-booster-pack');
91
119
 
92
120
  // Batch form, up to 200 ids at once β€” ids only, not technicalNames.
93
- const { data: cards } = await tcgpriser.cards.list({ search: 'pikachu' });
121
+ const { data: cards } = await tcgpriser.cards.list({ limit: 20 });
94
122
  await tcgpriser.cards.pricingBatch(cards.map((card) => card.id));
95
123
  ```
96
124
 
@@ -110,6 +138,26 @@ await tcgpriser.priceStats.estimatedValues({ expansion: 'eng-scarlet-violet-jour
110
138
  await tcgpriser.bargains.list({ type: 'sealed' }); // 'sealed' | 'card' | 'all'
111
139
  ```
112
140
 
141
+ `priceStats` covers cards and sealed products together. To scope to one kind β€” so an `expansion`
142
+ filter doesn't pull in that set's single cards alongside its booster boxes β€” use the equivalents on
143
+ `cards` and `products`:
144
+
145
+ ```typescript
146
+ await tcgpriser.cards.dailyStats({ expansion: 'eng-scarlet-violet-journey-together' });
147
+ await tcgpriser.products.estimatedValues({ expansion: 'eng-scarlet-violet-journey-together' });
148
+ ```
149
+
150
+ ### Enumerating the catalog
151
+
152
+ `technicalNames()` returns every slug with its `updatedAt` and nothing else β€” no pricing joins, no
153
+ paging through full documents. It's what you want for a sitemap, or to work out which items have
154
+ changed since your last sync:
155
+
156
+ ```typescript
157
+ const { data: slugs } = await tcgpriser.cards.technicalNames();
158
+ const stale = slugs.filter((slug) => slug.updatedAt > lastSyncedAt);
159
+ ```
160
+
113
161
  ### Pack rates
114
162
 
115
163
  ```typescript
@@ -119,35 +167,69 @@ await tcgpriser.packRates.get(expansionId);
119
167
 
120
168
  ## Available Methods
121
169
 
170
+ πŸ”’ = premium, 🏒 = business. Both need an API token β€” see [Authentication](#authentication). The
171
+ Credits column applies only to calls that draw from your weekly allowance; see [Credits](#credits).
172
+
122
173
  ### `cards`
123
174
 
175
+ `list()`/`dailyStats()`/`estimatedValues()` all take `brand`/`productLine` filters β€” see
176
+ [Brands](#brands). `get()` takes `brand` too, to disambiguate a technicalName two brands share.
177
+
124
178
  | Method | Description | Credits |
125
179
  |---|---|---|
126
- | `list(params)` | Search or list cards | β€” |
127
- | `get(id)` | Fetch one card by id or technicalName | β€” |
180
+ | `list(params)` | List cards, newest first | β€” |
181
+ | `search(params)` πŸ”’ | Free-text search on card and set names | 5 |
182
+ | `get(id, params)` | Fetch one card by id or technicalName | β€” |
128
183
  | `matches(id, params)` | Current shop listings matched to this card | β€” |
184
+ | `pricing(id)` | This card's current pricing snapshot | β€” |
185
+ | `pricingBatch(ids)` | Pricing for up to 200 cards at once, by id | β€” |
186
+ | `technicalNames()` | Every card's slug and `updatedAt`, for sitemaps and syncs | β€” |
187
+ | `dailyStats(params)` | Daily average price history, cards only | β€” |
188
+ | `estimatedValues(params)` | Current estimated market value, cards only | β€” |
129
189
  | `prices(id, params)` πŸ”’ | Individual marketplace sale records | 2 |
130
190
  | `referencePrices(id, params)` πŸ”’ | Cardmarket / TCGplayer / eBay / Tradera price history | 2 |
131
191
  | `livePricing(id)` πŸ”’ | Pricing computed fresh for this request | 3 |
132
192
 
133
193
  ### `products`
134
194
 
195
+ Sealed products only. Single cards live under `cards`. `list()`/`dailyStats()`/`estimatedValues()`
196
+ take `brand`/`productLine` filters, and `get()` takes `brand` β€” see [Brands](#brands).
197
+
135
198
  | Method | Description | Credits |
136
199
  |---|---|---|
137
- | `list(params)` | Search or list sealed products | β€” |
138
- | `get(id)` | Fetch one product by id or technicalName | β€” |
200
+ | `list(params)` | List sealed products, newest first | β€” |
201
+ | `search(params)` πŸ”’ | Free-text search on the product name | 5 |
202
+ | `get(id, params)` | Fetch one product by id or technicalName | β€” |
139
203
  | `matches(id, params)` | Current shop listings matched to this product | β€” |
204
+ | `pricing(id)` | This product's current pricing snapshot | β€” |
205
+ | `pricingBatch(ids)` | Pricing for up to 200 products at once, by id | β€” |
206
+ | `technicalNames()` | Every product's slug and `updatedAt` | β€” |
207
+ | `dailyStats(params)` | Daily average price history, sealed only | β€” |
208
+ | `estimatedValues(params)` | Current estimated market value, sealed only | β€” |
140
209
  | `prices(id, params)` πŸ”’ | Individual marketplace sale records | 2 |
141
210
  | `referencePrices(id, params)` πŸ”’ | Cardmarket / TCGplayer / Tradera price history | 2 |
142
211
  | `livePricing(id)` πŸ”’ | Pricing computed fresh for this request | 3 |
143
212
 
144
213
  ### `expansions`
145
214
 
215
+ Cards and sealed products are always separate calls β€” nothing here merges them. `list()` takes a
216
+ `brand` filter β€” see [Brands](#brands).
217
+
146
218
  | Method | Description | Credits |
147
219
  |---|---|---|
148
- | `list()` | Every expansion | β€” |
149
- | `products(technicalName)` | Every card and sealed product in one expansion | β€” |
150
- | `livePricing(technicalName)` πŸ”’ | Fresh pricing for every item in one expansion | 8 |
220
+ | `list(params)` | Every expansion, with counts | β€” |
221
+ | `get(technicalName)` | One expansion's metadata (no contents) | β€” |
222
+ | `cards(technicalName)` | Every card in the expansion, content only | β€” |
223
+ | `sealedProducts(technicalName)` | Every sealed product in the expansion, content only | β€” |
224
+ | `cardsLivePricing(technicalName)` πŸ”’ | Fresh pricing for every card in the expansion | 8 |
225
+ | `productsLivePricing(technicalName)` πŸ”’ | Fresh pricing for every sealed product in it | 8 |
226
+
227
+ ### `brands`
228
+
229
+ | Method | Description |
230
+ |---|---|
231
+ | `list()` | Every brand the catalogue carries |
232
+ | `get(id)` | Fetch one brand by id or technicalName |
151
233
 
152
234
  ### `shops`
153
235
 
@@ -174,6 +256,9 @@ await tcgpriser.packRates.get(expansionId);
174
256
 
175
257
  ### `priceStats`
176
258
 
259
+ `daily()` and `estimatedValues()` cover cards and sealed products together. For one or the other,
260
+ use `cards.dailyStats()` / `products.dailyStats()` and their `estimatedValues()` counterparts.
261
+
177
262
  | Method | Description | Credits |
178
263
  |---|---|---|
179
264
  | `daily(params)` | Daily average price history | β€” |
@@ -208,15 +293,24 @@ await tcgpriser.packRates.get(expansionId);
208
293
  | `submit(params)` | Submit a shop URL for scraping |
209
294
  | `assignProduct(id, params)` | Manually assign (or clear) the product a URL resolves to |
210
295
 
296
+ ### `webhooks` 🏒
297
+
298
+ Business tier. See [Webhooks](#webhooks) below.
299
+
300
+ | Method | Description |
301
+ |---|---|
302
+ | `create(params)` | Register a webhook; returns its signing secret once |
303
+ | `list()` | Every webhook on the account |
304
+ | `delete(id)` | Revoke a webhook |
305
+ | `test(id)` | Send a sample delivery |
306
+
211
307
  ### `stats`
212
308
 
213
309
  | Method | Description |
214
310
  |---|---|
215
311
  | `platform()` | Platform-wide overview counts |
216
312
 
217
- πŸ”’ = premium, needs an API token. See below. The Credits column applies only to methods that draw
218
- from your weekly credit allowance when called with an API token. A session login (not available to
219
- this client) and `shopUrls` are exempt. See [Credits](#credits).
313
+ Every method also takes `signal` and `timeoutMs` β€” see [Timeouts and cancellation](#timeouts-and-cancellation).
220
314
 
221
315
  ## Authentication
222
316
 
@@ -253,8 +347,9 @@ try {
253
347
  Methods marked with a credit count in the tables above draw from your account's weekly credit
254
348
  allowance when called with an API token (Premium: 1500/week, Business: 6000/week, both reset Monday
255
349
  00:00 UTC). Cost is weighted by how much work the call does server-side: a cached single-item lookup
256
- costs less than a whole-expansion recompute. `expansions.livePricing` is the most expensive call in
257
- the API at 8 credits, since it recomputes pricing for every card or product in the set.
350
+ costs less than a whole-expansion recompute. `expansions.cardsLivePricing()` and
351
+ `expansions.productsLivePricing()` are the most expensive calls in the API at 8 credits, since each
352
+ recomputes pricing for every item in the set.
258
353
 
259
354
  Once the week's credits run out, further calls reject with `429 creditsExhausted`, surfaced the same
260
355
  way as any other error:
@@ -269,9 +364,24 @@ try {
269
364
  }
270
365
  ```
271
366
 
272
- This client doesn't currently surface the `X-Credits-Remaining` response header. To track your
273
- remaining balance mid-week, read it off the raw HTTP response yourself outside this SDK, or poll
274
- `GET /subscription` on the main API.
367
+ To track your balance mid-week, read `creditsRemaining` off the client. It's updated from the
368
+ `X-Credits-Remaining` header the API returns on every charged response, so it costs no extra
369
+ request β€” but it's only as current as your last premium call, and `undefined` until you make one:
370
+
371
+ ```typescript
372
+ await tcgpriser.cards.livePricing('fezandipiti-ex');
373
+ console.log(tcgpriser.creditsRemaining); // 1487
374
+ ```
375
+
376
+ It's also on the error, which is where it matters most:
377
+
378
+ ```typescript
379
+ catch (error) {
380
+ if (error instanceof TcgPriserError && error.code === 'creditsExhausted') {
381
+ console.log(error.creditsRemaining); // 0
382
+ }
383
+ }
384
+ ```
275
385
 
276
386
  ## Options
277
387
 
@@ -287,10 +397,83 @@ new TcgPriser({
287
397
  baseUrl: 'https://api.tcgpriser.se', // default; point at a local dev server instead
288
398
  headers: { 'User-Agent': 'my-app/1.0' },
289
399
  fetch: myCustomFetch, // defaults to global fetch (Node 18+)
400
+ timeoutMs: 60_000, // default; 0 disables the timeout entirely
290
401
  },
291
402
  });
292
403
  ```
293
404
 
405
+ ## Timeouts and cancellation
406
+
407
+ Every method takes `timeoutMs` and `signal`, either as a second argument or alongside the other
408
+ params:
409
+
410
+ ```typescript
411
+ await tcgpriser.cards.get('fezandipiti-ex', { timeoutMs: 5000 });
412
+ await tcgpriser.cards.list({ limit: 10, timeoutMs: 5000 });
413
+ ```
414
+
415
+ Requests time out after 60 seconds by default. A timeout rejects with a `TcgPriserError` whose
416
+ `code` is `'timeout'` β€” the one code this package raises itself, so a stalled connection is always
417
+ distinguishable from a server that actually answered:
418
+
419
+ ```typescript
420
+ try {
421
+ await tcgpriser.expansions.cardsLivePricing('eng-scarlet-violet-journey-together');
422
+ } catch (error) {
423
+ if (error instanceof TcgPriserError && error.code === 'timeout') {
424
+ // A whole-expansion recompute on a large set is the one call worth raising the limit for.
425
+ await tcgpriser.expansions.cardsLivePricing('eng-scarlet-violet-journey-together', {
426
+ timeoutMs: 0, // no timeout
427
+ });
428
+ }
429
+ }
430
+ ```
431
+
432
+ Pass a `signal` to cancel from outside β€” a user navigating away, a request being abandoned. Whichever
433
+ fires first wins, and aborting through your own signal rejects with the standard `AbortError` rather
434
+ than a `TcgPriserError`, since that's you getting what you asked for:
435
+
436
+ ```typescript
437
+ const controller = new AbortController();
438
+ setTimeout(() => controller.abort(), 1000);
439
+ await tcgpriser.cards.list({ limit: 10, signal: controller.signal });
440
+ ```
441
+
442
+ ## Rate limits
443
+
444
+ Anonymous traffic is capped per IP, and premium reads per token (Premium 30/min, Business 120/min).
445
+ Going over rejects with `429 rateLimited`, carrying the seconds to wait:
446
+
447
+ ```typescript
448
+ if (error instanceof TcgPriserError && error.code === 'rateLimited') {
449
+ await sleep((error.retryAfter ?? 60) * 1000);
450
+ }
451
+ ```
452
+
453
+ ## Webhooks
454
+
455
+ Business tier only β€” a Premium token gets `403 businessRequired`. Instead of polling, the API POSTs
456
+ to a URL you register when a catalog event fires.
457
+
458
+ ```typescript
459
+ const tcgpriser = new TcgPriser(myBusinessApiToken);
460
+
461
+ const webhook = await tcgpriser.webhooks.create({
462
+ url: 'https://example.com/hooks/tcgpriser', // must be https
463
+ events: ['price.updated', 'bargain.found'],
464
+ });
465
+
466
+ // The only time you will ever see this. Store it now β€” deliveries are signed with it, and no
467
+ // endpoint reads it back. Lost it? Delete the webhook and register a new one.
468
+ await saveSecret(webhook.secret);
469
+
470
+ await tcgpriser.webhooks.test(webhook.id); // sample delivery, so you can verify your endpoint
471
+ await tcgpriser.webhooks.list(); // never includes secrets
472
+ await tcgpriser.webhooks.delete(webhook.id);
473
+ ```
474
+
475
+ Available events: `price.updated`, `bargain.found`, `product.created`, `card.created`.
476
+
294
477
  ## Types
295
478
 
296
479
  Every response type is exported from the package root:
@@ -392,7 +575,19 @@ Runs `examples/rehost-images.ts` β€” the "Images" section's rehosting pattern ag
392
575
 
393
576
  ## Scope
394
577
 
395
- Covers the API's full documented surface: public catalog, price and bargain reads, plus the premium endpoints above. Not covered: the admin/scraper/auth surface, which isn't part of any published contract.
578
+ Covers everything on the API's two published documentation pages: the public catalog, price and
579
+ bargain reads at [/docs](https://api.tcgpriser.se/docs), and the premium and business endpoints at
580
+ [/premium-docs](https://api.tcgpriser.se/premium-docs).
581
+
582
+ Not covered, deliberately:
583
+
584
+ - **The admin, scraper and ingest surface.** Ours, not a customer's β€” no API token reaches it, and
585
+ it isn't part of any published contract.
586
+ - **Account management** (login, subscriptions, API-token minting, referrals). These authenticate
587
+ with the website's session JWT, which comes from an OAuth flow this client can't drive. Generate
588
+ your API token from your account page instead.
589
+ - **`/bulk-export`.** Feeds our own static build, answers a non-standard shape, and is not
590
+ documented for third parties. Page the documented endpoints instead.
396
591
 
397
592
  ## License
398
593