tcgpriser 0.5.0 → 0.10.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
@@ -36,7 +36,12 @@ import { TcgPriser } from 'tcgpriser';
36
36
  const tcgpriser = new TcgPriser();
37
37
 
38
38
  const card = await tcgpriser.cards.get('mega-evolution-ascended-heroes-fezandipiti-ex');
39
- console.log(card.retailPrice, card.lowestShopOffer?.shop.name);
39
+ console.log(card.name, card.expansion?.name);
40
+
41
+ // Content (name, images, expansion, ...) and pricing (retailPrice, lowestShopOffer, ...) are
42
+ // separate, differently-cached calls — see "Pricing" below.
43
+ const pricing = await tcgpriser.cards.pricing('mega-evolution-ascended-heroes-fezandipiti-ex');
44
+ console.log(pricing.retailPrice, pricing.lowestShopOffer?.shop.name);
40
45
 
41
46
  const { data: bargains } = await tcgpriser.bargains.list({ type: 'card' });
42
47
  ```
@@ -63,9 +68,30 @@ await tcgpriser.products.matches('scarlet-violet-booster-pack');
63
68
 
64
69
  ### Expansions
65
70
 
71
+ Cards and sealed products are never merged into one response — fetch each separately.
72
+
66
73
  ```typescript
67
74
  await tcgpriser.expansions.list();
68
- await tcgpriser.expansions.products('eng-scarlet-violet-journey-together'); // { expansion, cards, sealed }
75
+ await tcgpriser.expansions.get('eng-scarlet-violet-journey-together'); // metadata only
76
+ await tcgpriser.expansions.cards('eng-scarlet-violet-journey-together'); // content only
77
+ await tcgpriser.expansions.sealedProducts('eng-scarlet-violet-journey-together'); // content only
78
+ ```
79
+
80
+ ### Pricing
81
+
82
+ `list()`/`get()`/`expansions.cards()`/`expansions.sealedProducts()` all return catalog content
83
+ only — name, images, brand, expansion, rarity. Pricing (`retailPrice`, `estimatedValue`,
84
+ `lowestShopOffer`, `referencePriceSnapshotsByProvider`) is a separate, shorter-cached call: content changes on an
85
+ admin edit or catalog import, pricing refreshes daily, so each is cached at the TTL its own
86
+ freshness supports.
87
+
88
+ ```typescript
89
+ await tcgpriser.cards.pricing('fezandipiti-ex'); // id or technicalName
90
+ await tcgpriser.products.pricing('scarlet-violet-booster-pack');
91
+
92
+ // Batch form, up to 200 ids at once — ids only, not technicalNames.
93
+ const { data: cards } = await tcgpriser.cards.list({ search: 'pikachu' });
94
+ await tcgpriser.cards.pricingBatch(cards.map((card) => card.id));
69
95
  ```
70
96
 
71
97
  ### Shops
@@ -95,33 +121,33 @@ await tcgpriser.packRates.get(expansionId);
95
121
 
96
122
  ### `cards`
97
123
 
98
- | Method | Description |
99
- |---|---|
100
- | `list(params)` | Search or list cards |
101
- | `get(id)` | Fetch one card by id or technicalName |
102
- | `matches(id, params)` | Current shop listings matched to this card |
103
- | `prices(id, params)` 🔒 | Individual marketplace sale records |
104
- | `referencePrices(id, params)` 🔒 | Cardmarket / TCGplayer / eBay / Tradera price history |
105
- | `livePricing(id)` 🔒 | Pricing computed fresh for this request |
124
+ | Method | Description | Credits |
125
+ |---|---|---|
126
+ | `list(params)` | Search or list cards | — |
127
+ | `get(id)` | Fetch one card by id or technicalName | — |
128
+ | `matches(id, params)` | Current shop listings matched to this card | — |
129
+ | `prices(id, params)` 🔒 | Individual marketplace sale records | 2 |
130
+ | `referencePrices(id, params)` 🔒 | Cardmarket / TCGplayer / eBay / Tradera price history | 2 |
131
+ | `livePricing(id)` 🔒 | Pricing computed fresh for this request | 3 |
106
132
 
107
133
  ### `products`
108
134
 
109
- | Method | Description |
110
- |---|---|
111
- | `list(params)` | Search or list sealed products |
112
- | `get(id)` | Fetch one product by id or technicalName |
113
- | `matches(id, params)` | Current shop listings matched to this product |
114
- | `prices(id, params)` 🔒 | Individual marketplace sale records |
115
- | `referencePrices(id, params)` 🔒 | Cardmarket / TCGplayer / Tradera price history |
116
- | `livePricing(id)` 🔒 | Pricing computed fresh for this request |
135
+ | Method | Description | Credits |
136
+ |---|---|---|
137
+ | `list(params)` | Search or list sealed products | — |
138
+ | `get(id)` | Fetch one product by id or technicalName | — |
139
+ | `matches(id, params)` | Current shop listings matched to this product | — |
140
+ | `prices(id, params)` 🔒 | Individual marketplace sale records | 2 |
141
+ | `referencePrices(id, params)` 🔒 | Cardmarket / TCGplayer / Tradera price history | 2 |
142
+ | `livePricing(id)` 🔒 | Pricing computed fresh for this request | 3 |
117
143
 
118
144
  ### `expansions`
119
145
 
120
- | Method | Description |
121
- |---|---|
122
- | `list()` | Every expansion |
123
- | `products(technicalName)` | Every card and sealed product in one expansion |
124
- | `livePricing(technicalName)` 🔒 | Fresh pricing for every item in one expansion |
146
+ | Method | Description | Credits |
147
+ |---|---|---|
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 |
125
151
 
126
152
  ### `shops`
127
153
 
@@ -140,33 +166,33 @@ await tcgpriser.packRates.get(expansionId);
140
166
 
141
167
  ### `shopMatchStats` 🔒
142
168
 
143
- | Method | Description |
144
- |---|---|
145
- | `forProduct(id, params)` | One product's price history, broken out per shop |
146
- | `forShop(shop, params)` | One shop's price history, broken out per product |
147
- | `compare(params)` | One product's price at every shop that carries it |
169
+ | Method | Description | Credits |
170
+ |---|---|---|
171
+ | `forProduct(id, params)` | One product's price history, broken out per shop | 3 |
172
+ | `forShop(shop, params)` | One shop's price history, broken out per product | 3 |
173
+ | `compare(params)` | One product's price at every shop that carries it | 3 |
148
174
 
149
175
  ### `priceStats`
150
176
 
151
- | Method | Description |
152
- |---|---|
153
- | `daily(params)` | Daily average price history |
154
- | `estimatedValues(params)` | Current estimated market value |
155
- | `topProducts(params)` | Items ranked by shop availability |
156
- | `product(id)` 🔒 | Daily history, estimate and variant summary for one product |
157
- | `productFull(id)` 🔒 | `product()` plus the item's current shop matches |
158
- | `productDaily(id, params)` 🔒 | Daily history for one product, custom window |
159
- | `productDailyLast30(id)` 🔒 | Daily history, fixed to the last 30 days |
160
- | `productEstimatedValue(id)` 🔒 | Current estimated value only |
161
- | `productByVariant(id, params)` 🔒 | Price stats per card condition/grade |
162
- | `productDailyByVariant(id, params)` 🔒 | Daily history for one condition/grade |
177
+ | Method | Description | Credits |
178
+ |---|---|---|
179
+ | `daily(params)` | Daily average price history | — |
180
+ | `estimatedValues(params)` | Current estimated market value | — |
181
+ | `topProducts(params)` | Items ranked by shop availability | — |
182
+ | `product(id)` 🔒 | Daily history, estimate and variant summary for one product | 2 |
183
+ | `productFull(id)` 🔒 | `product()` plus the item's current shop matches | 5 |
184
+ | `productDaily(id, params)` 🔒 | Daily history for one product, custom window | 1 |
185
+ | `productDailyLast30(id)` 🔒 | Daily history, fixed to the last 30 days | 1 |
186
+ | `productEstimatedValue(id)` 🔒 | Current estimated value only | 2 |
187
+ | `productByVariant(id, params)` 🔒 | Price stats per card condition/grade | 3 |
188
+ | `productDailyByVariant(id, params)` 🔒 | Daily history for one condition/grade | 1 |
163
189
 
164
190
  ### `bargains`
165
191
 
166
- | Method | Description |
167
- |---|---|
168
- | `list(params)` | Current listings priced below their reference price |
169
- | `search(params)` 🔒 | Same, with real pagination and filters |
192
+ | Method | Description | Credits |
193
+ |---|---|---|
194
+ | `list(params)` | Current listings priced below their reference price | — |
195
+ | `search(params)` 🔒 | Same, with real pagination and filters | 5 |
170
196
 
171
197
  ### `packRates`
172
198
 
@@ -188,14 +214,16 @@ await tcgpriser.packRates.get(expansionId);
188
214
  |---|---|
189
215
  | `platform()` | Platform-wide overview counts |
190
216
 
191
- 🔒 = premium, needs a subscriber's token. See below.
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).
192
220
 
193
221
  ## Authentication
194
222
 
195
- Public methods work with no setup. Premium methods (marked 🔒 above) need a signed-in subscriber's JWT from tcgpriser.se. This package doesn't handle login itself; the site's sign-in is OAuth-based, so a headless client can't drive it. Pass a token your own app already has:
223
+ Public methods work with no setup. Premium methods (marked 🔒 above) need a Premium subscriber's API token, generated from **tcgpriser.se/account/api-token**. Unlike the site's own session login, which is OAuth-based and can't be driven headlessly, the API token is a long-lived, revocable secret made specifically for scripts and other programmatic callers. Generate it once from your account page and pass it in:
196
224
 
197
225
  ```typescript
198
- const tcgpriser = new TcgPriser(myJwt); // shorthand for { authToken: myJwt }
226
+ const tcgpriser = new TcgPriser(myApiToken); // shorthand for { authToken: myApiToken }
199
227
  await tcgpriser.cards.livePricing('fezandipiti-ex');
200
228
  ```
201
229
 
@@ -203,7 +231,7 @@ Or set no default and pass a token per call, which fits better when one client i
203
231
 
204
232
  ```typescript
205
233
  const tcgpriser = new TcgPriser();
206
- await tcgpriser.cards.livePricing('fezandipiti-ex', { authToken: requestUserJwt });
234
+ await tcgpriser.cards.livePricing('fezandipiti-ex', { authToken: requestUserApiToken });
207
235
  ```
208
236
 
209
237
  A missing or invalid token gets `401 unauthorized`. A valid token without an active subscription gets `403 premiumRequired`. Both come back as `TcgPriserError`:
@@ -220,14 +248,39 @@ try {
220
248
  }
221
249
  ```
222
250
 
251
+ ## Credits
252
+
253
+ Methods marked with a credit count in the tables above draw from your account's weekly credit
254
+ allowance when called with an API token (Premium: 1500/week, Business: 6000/week, both reset Monday
255
+ 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.
258
+
259
+ Once the week's credits run out, further calls reject with `429 creditsExhausted`, surfaced the same
260
+ way as any other error:
261
+
262
+ ```typescript
263
+ try {
264
+ await tcgpriser.cards.livePricing('fezandipiti-ex');
265
+ } catch (error) {
266
+ if (error instanceof TcgPriserError && error.code === 'creditsExhausted') {
267
+ console.log('Out of credits for this week:', error.message);
268
+ }
269
+ }
270
+ ```
271
+
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.
275
+
223
276
  ## Options
224
277
 
225
278
  ```typescript
226
- new TcgPriser(myJwt); // shorthand for { authToken: myJwt }
227
- new TcgPriser(); // no token, public methods only
279
+ new TcgPriser(myApiToken); // shorthand for { authToken: myApiToken }
280
+ new TcgPriser(); // no token, public methods only
228
281
 
229
282
  new TcgPriser({
230
- authToken: myJwt,
283
+ authToken: myApiToken,
231
284
 
232
285
  // Local dev, self-hosting or tests only. Leave this out for normal use.
233
286
  advanced: {
@@ -256,6 +309,47 @@ type CardSchema = components['schemas']['CardWithPricing'];
256
309
 
257
310
  Most types in `tcgpriser` are direct aliases onto that generated schema, so a field in your code and a field in the API docs are the same field, always.
258
311
 
312
+ ## Images
313
+
314
+ `imageUrl`, `logoUrl` and `symbolUrl` fields point at tcgpriser.se's own CDN, which is sized for
315
+ tcgpriser.se's own traffic, not for hotlinking from other sites and apps. **For best performance,
316
+ rehost these images on your own storage/CDN and cache them there** instead of linking to them
317
+ directly — one less hop, tuned to your own traffic and geography, and no dependency on
318
+ infrastructure that isn't yours.
319
+
320
+ A simple way to do this: fetch the image once, save it under the URL's path (e.g.
321
+ `products/eng-scarlet-violet-booster-pack.png`) as a stable local key, serve it from your own
322
+ storage from then on, and periodically re-fetch (a nightly job is plenty) using a conditional
323
+ `GET` so you only pay for images that actually changed:
324
+
325
+ ```typescript
326
+ import { mkdir, writeFile } from 'node:fs/promises';
327
+ import { dirname, join } from 'node:path';
328
+
329
+ const etags = new Map<string, string>(); // persist this however you persist anything else
330
+
331
+ async function rehostImage(imageUrl: string, cacheDir: string): Promise<string> {
332
+ const key = new URL(imageUrl).pathname.replace(/^\/[^/]+\//, ''); // "products/....webp"
333
+ const localPath = join(cacheDir, key);
334
+ const knownEtag = etags.get(key);
335
+
336
+ const res = await fetch(imageUrl, { headers: knownEtag ? { 'If-None-Match': knownEtag } : {} });
337
+ if (res.status === 304) return localPath; // unchanged since last sync
338
+
339
+ if (!res.ok) throw new Error(`Failed to fetch ${imageUrl}: ${res.status}`);
340
+ await mkdir(dirname(localPath), { recursive: true });
341
+ await writeFile(localPath, Buffer.from(await res.arrayBuffer()));
342
+
343
+ const etag = res.headers.get('etag');
344
+ if (etag) etags.set(key, etag);
345
+ return localPath;
346
+ }
347
+ ```
348
+
349
+ Swap the `fs`/`mkdir`/`writeFile` calls for your own storage's SDK (S3, R2, Cloudflare Images, ...)
350
+ if you're not caching to local disk. See `examples/rehost-images.ts` for a runnable version of this
351
+ against a live `tcgpriser` response.
352
+
259
353
  ## Scripts
260
354
 
261
355
  ### Build
@@ -290,6 +384,12 @@ yarn example
290
384
 
291
385
  Runs `examples/basic.ts` against a local dev API. Set `TCGPRISER_AUTH_TOKEN` to see the premium call succeed instead of the expected 401.
292
386
 
387
+ ```bash
388
+ yarn example:rehost-images
389
+ ```
390
+
391
+ Runs `examples/rehost-images.ts` — the "Images" section's rehosting pattern against a handful of real product images. Run it twice to see the second pass come back as `304`s.
392
+
293
393
  ## Scope
294
394
 
295
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.
package/dist/index.cjs CHANGED
@@ -44,6 +44,8 @@ var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
44
44
  "readOnlyField",
45
45
  "rateLimited",
46
46
  "premiumRequired",
47
+ "businessRequired",
48
+ "creditsExhausted",
47
49
  "internalError"
48
50
  ]);
49
51
  async function toApiError(res, url) {
@@ -169,6 +171,21 @@ var CardsResource = class {
169
171
  livePricing(idOrTechnicalName, options = {}) {
170
172
  return this.http.get(`/cards/${encodeURIComponent(idOrTechnicalName)}/pricing/live`, options);
171
173
  }
174
+ /** `GET /cards/{id}/pricing`: this card's current pricing snapshot — `retailPrice`,
175
+ * `estimatedValue`, `lowestShopOffer`, `referencePriceSnapshotsByProvider` — refreshed once a day
176
+ * by the nightly pricing/scraper jobs. `get()` returns content only; this is the separate,
177
+ * shorter-cached call for the part of a card that actually changes day to day. */
178
+ pricing(idOrTechnicalName) {
179
+ return this.http.get(`/cards/${encodeURIComponent(idOrTechnicalName)}/pricing`);
180
+ }
181
+ /** `GET /cards/pricing`: pricing for up to 200 cards in one request, keyed by `id` — the batch
182
+ * counterpart to `pricing()`, for a page of results (a search page, an expansion's contents) that
183
+ * needs pricing for many items at once. Unlike `get()`/`pricing()`, this only accepts `id`s, not
184
+ * technicalNames — pass the `id`s already on the cards you fetched. Ids with no match are
185
+ * silently omitted from the result rather than causing an error. */
186
+ pricingBatch(ids) {
187
+ return this.http.get(`/cards/pricing?ids=${ids.map(encodeURIComponent).join(",")}`);
188
+ }
172
189
  };
173
190
 
174
191
  // src/resources/expansions.ts
@@ -182,14 +199,38 @@ var ExpansionsResource = class {
182
199
  const res = await this.http.get("/expansions");
183
200
  return res.data;
184
201
  }
185
- /** `GET /expansions/{technicalName}/products`: every card and sealed product in one
186
- * expansion, kept as separate `cards`/`sealed` groups. */
187
- products(technicalName) {
188
- return this.http.get(`/expansions/${encodeURIComponent(technicalName)}/products?grouped=true`);
202
+ /** `GET /expansions/{technicalName}`: metadata only no cards or sealed products. Returns the
203
+ * smaller `ExpansionRef`, not the full `Expansion`: this is a plain lookup by technicalName, not
204
+ * the aggregation `list()` runs, so `sealedCount`/`cardCount`/`productCount` aren't available
205
+ * here. See `cards()` and `sealedProducts()` for this expansion's contents. */
206
+ get(technicalName) {
207
+ return this.http.get(`/expansions/${encodeURIComponent(technicalName)}`);
208
+ }
209
+ /** `GET /expansions/{technicalName}/cards`: every card in this expansion. Content only, no
210
+ * pricing fields — pass the `id`s from the result to `client.cards.pricingBatch()` if you need
211
+ * pricing too. Sealed products are a separate call — see `sealedProducts()` — never merged into
212
+ * this one. */
213
+ cards(technicalName) {
214
+ return this.http.get(`/expansions/${encodeURIComponent(technicalName)}/cards`);
215
+ }
216
+ /** `GET /expansions/{technicalName}/products`: every sealed product in this expansion. Content
217
+ * only, no pricing fields — pass the `id`s from the result to `client.products.pricingBatch()`
218
+ * if you need pricing too. Cards are a separate call — see `cards()` — never merged into this
219
+ * one. */
220
+ sealedProducts(technicalName) {
221
+ return this.http.get(`/expansions/${encodeURIComponent(technicalName)}/products`);
222
+ }
223
+ /** `GET /expansions/{technicalName}/cards/live-pricing`: computed fresh for every card in this
224
+ * expansion, not read from the last stats job. Premium. */
225
+ cardsLivePricing(technicalName, options = {}) {
226
+ return this.http.get(
227
+ `/expansions/${encodeURIComponent(technicalName)}/cards/live-pricing`,
228
+ options
229
+ );
189
230
  }
190
- /** `GET /expansions/{technicalName}/products/live-pricing`: computed fresh for every item in
191
- * this expansion, not read from the last stats job. Premium. */
192
- livePricing(technicalName, options = {}) {
231
+ /** `GET /expansions/{technicalName}/products/live-pricing`: computed fresh for every sealed
232
+ * product in this expansion, not read from the last stats job. Premium. */
233
+ productsLivePricing(technicalName, options = {}) {
193
234
  return this.http.get(
194
235
  `/expansions/${encodeURIComponent(technicalName)}/products/live-pricing`,
195
236
  options
@@ -331,6 +372,21 @@ var ProductsResource = class {
331
372
  livePricing(idOrTechnicalName, options = {}) {
332
373
  return this.http.get(`/product/${encodeURIComponent(idOrTechnicalName)}/pricing/live`, options);
333
374
  }
375
+ /** `GET /product/{id}/pricing`: this product's current pricing snapshot — `retailPrice`,
376
+ * `estimatedValue`, `lowestShopOffer`, `referencePriceSnapshotsByProvider` — refreshed once a day
377
+ * by the nightly pricing/scraper jobs. `get()` returns content only; this is the separate,
378
+ * shorter-cached call for the part of a product that actually changes day to day. */
379
+ pricing(idOrTechnicalName) {
380
+ return this.http.get(`/product/${encodeURIComponent(idOrTechnicalName)}/pricing`);
381
+ }
382
+ /** `GET /product/pricing`: pricing for up to 200 sealed products in one request, keyed by `id` —
383
+ * the batch counterpart to `pricing()`, for a page of results (a search page, an expansion's
384
+ * contents) that needs pricing for many items at once. Unlike `get()`/`pricing()`, this only
385
+ * accepts `id`s, not technicalNames — pass the `id`s already on the products you fetched. Ids with
386
+ * no match are silently omitted from the result rather than causing an error. */
387
+ pricingBatch(ids) {
388
+ return this.http.get(`/product/pricing?ids=${ids.map(encodeURIComponent).join(",")}`);
389
+ }
334
390
  };
335
391
 
336
392
  // src/resources/shopMatchStats.ts
@@ -448,7 +504,7 @@ var TcgPriser = class {
448
504
  packRates;
449
505
  stats;
450
506
  /**
451
- * @param optionsOrAuthToken A subscriber JWT (`new TcgPriser(myJwt)`), a full
507
+ * @param optionsOrAuthToken A subscriber's API token (`new TcgPriser(myApiToken)`), a full
452
508
  * `TcgPriserOptions` object, or omit it entirely for an anonymous, public-only client.
453
509
  */
454
510
  constructor(optionsOrAuthToken) {