google-play-scraper-fetch 0.1.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/dist/index.js ADDED
@@ -0,0 +1,496 @@
1
+ // src/constants.ts
2
+ var BASE_URL = "https://play.google.com";
3
+ var sort = {
4
+ NEWEST: 2,
5
+ RATING: 3,
6
+ HELPFULNESS: 1
7
+ };
8
+
9
+ // src/errors.ts
10
+ var PlayScraperError = class extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "PlayScraperError";
14
+ }
15
+ };
16
+ var PlayScraperHttpError = class extends PlayScraperError {
17
+ status;
18
+ url;
19
+ constructor(message, status, url) {
20
+ super(message);
21
+ this.name = "PlayScraperHttpError";
22
+ this.status = status;
23
+ this.url = url;
24
+ }
25
+ };
26
+ var PlayScraperParseError = class extends PlayScraperError {
27
+ context;
28
+ constructor(message, context) {
29
+ super(`${message} (${context})`);
30
+ this.name = "PlayScraperParseError";
31
+ this.context = context;
32
+ }
33
+ };
34
+ function snippet(value, max = 200) {
35
+ let text;
36
+ try {
37
+ text = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
38
+ } catch {
39
+ text = String(value);
40
+ }
41
+ return text.length > max ? `${text.slice(0, max)}...` : text;
42
+ }
43
+
44
+ // src/htmlToText.ts
45
+ var NAMED_ENTITIES = {
46
+ amp: "&",
47
+ lt: "<",
48
+ gt: ">",
49
+ quot: '"',
50
+ apos: "'",
51
+ nbsp: "\xA0"
52
+ };
53
+ function htmlToText(html) {
54
+ return html.replace(/<br\s*\/?>/gi, "\r\n").replace(/<[^>]+>/g, "").replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (full, entity) => {
55
+ if (entity.startsWith("#x") || entity.startsWith("#X")) {
56
+ const code = Number.parseInt(entity.slice(2), 16);
57
+ return Number.isNaN(code) ? full : String.fromCodePoint(code);
58
+ }
59
+ if (entity.startsWith("#")) {
60
+ const code = Number.parseInt(entity.slice(1), 10);
61
+ return Number.isNaN(code) ? full : String.fromCodePoint(code);
62
+ }
63
+ return NAMED_ENTITIES[entity] ?? full;
64
+ });
65
+ }
66
+
67
+ // src/http.ts
68
+ async function httpRequest(url, options = {}) {
69
+ const fetchFn = options.fetch ?? fetch;
70
+ const response = await fetchFn(url, {
71
+ method: options.method ?? "GET",
72
+ body: options.body,
73
+ headers: options.headers,
74
+ redirect: "follow"
75
+ });
76
+ if (!response.ok) {
77
+ const message = response.status === 404 ? "App not found (404)" : `Error requesting Google Play: HTTP ${response.status}`;
78
+ throw new PlayScraperHttpError(message, response.status, url);
79
+ }
80
+ const setCookies = extractSetCookies(response.headers);
81
+ const body = await response.text();
82
+ return { body, setCookies };
83
+ }
84
+ function extractSetCookies(headers) {
85
+ const headersWithGetSetCookie = headers;
86
+ if (typeof headersWithGetSetCookie.getSetCookie === "function") {
87
+ return headersWithGetSetCookie.getSetCookie();
88
+ }
89
+ const single = headers.get("set-cookie");
90
+ return single === null ? [] : [single];
91
+ }
92
+ function cookieHeaderFromSetCookies(setCookies) {
93
+ const pairs = setCookies.map((cookieLine) => cookieLine.split(";", 1)[0]?.trim()).filter((pair) => pair !== void 0 && pair.includes("="));
94
+ return pairs.length > 0 ? pairs.join("; ") : void 0;
95
+ }
96
+
97
+ // src/path.ts
98
+ function pathGet(data, path) {
99
+ let current = data;
100
+ for (const key of path) {
101
+ if (current === null || current === void 0) return void 0;
102
+ if (typeof key === "number" && Array.isArray(current)) {
103
+ current = key < 0 ? current[current.length + key] : current[key];
104
+ continue;
105
+ }
106
+ if (typeof current === "object") {
107
+ current = current[String(key)];
108
+ continue;
109
+ }
110
+ return void 0;
111
+ }
112
+ return current;
113
+ }
114
+ function asString(value) {
115
+ return typeof value === "string" ? value : void 0;
116
+ }
117
+ function asNumber(value) {
118
+ return typeof value === "number" ? value : void 0;
119
+ }
120
+ function asArray(value) {
121
+ return Array.isArray(value) ? value : void 0;
122
+ }
123
+
124
+ // src/scriptData.ts
125
+ function parseScriptData(html) {
126
+ const scriptRegex = />AF_initDataCallback[\s\S]*?<\/script/g;
127
+ const keyRegex = /(ds:.*?)'/;
128
+ const valueRegex = /data:([\s\S]*?), sideChannel: {}}\);<\//;
129
+ const matches = html.match(scriptRegex);
130
+ if (!matches) {
131
+ throw new PlayScraperParseError(
132
+ "No AF_initDataCallback script tags found in the page",
133
+ `html starts with: ${snippet(html, 120)}`
134
+ );
135
+ }
136
+ const parsed = {};
137
+ for (const match of matches) {
138
+ const keyMatch = match.match(keyRegex);
139
+ const valueMatch = match.match(valueRegex);
140
+ if (!keyMatch || !valueMatch) continue;
141
+ const key = keyMatch[1];
142
+ const rawValue = valueMatch[1];
143
+ if (key === void 0 || rawValue === void 0) continue;
144
+ try {
145
+ parsed[key] = JSON.parse(rawValue);
146
+ } catch (error) {
147
+ throw new PlayScraperParseError(
148
+ `Failed to JSON.parse AF_initDataCallback payload for ${key}`,
149
+ snippet(rawValue)
150
+ );
151
+ }
152
+ }
153
+ return parsed;
154
+ }
155
+
156
+ // src/app.ts
157
+ async function app(options) {
158
+ if (!options || !options.appId) {
159
+ throw new PlayScraperParseError("appId missing", "app() requires an appId option");
160
+ }
161
+ const lang = options.lang ?? "en";
162
+ const country = options.country ?? "us";
163
+ const params = new URLSearchParams({ id: options.appId, hl: lang, gl: country });
164
+ const url = `${BASE_URL}/store/apps/details?${params.toString()}`;
165
+ const { body } = await httpRequest(url, { fetch: options.fetch });
166
+ const parsed = parseScriptData(body);
167
+ return extractAppDetails(parsed, options.appId, url);
168
+ }
169
+ function extractAppDetails(parsed, appId, url) {
170
+ const base = pathGet(parsed, ["ds:5", 1, 2]);
171
+ if (base === void 0 || base === null) {
172
+ throw new PlayScraperParseError(
173
+ "App data not found at ds:5[1][2]",
174
+ `available datasets: ${snippet(Object.keys(parsed))}`
175
+ );
176
+ }
177
+ const g = (...path) => pathGet(base, path);
178
+ const gWithFallback = (path, fallbackPath) => {
179
+ const primary = pathGet(base, path);
180
+ return primary === null || primary === void 0 ? pathGet(base, fallbackPath) : primary;
181
+ };
182
+ const title = asString(g(0, 0));
183
+ if (title === void 0) {
184
+ throw new PlayScraperParseError(
185
+ "App title not found at ds:5[1][2][0][0]",
186
+ `value: ${snippet(g(0, 0))}`
187
+ );
188
+ }
189
+ const descriptionHTML = extractDescriptionHtml(base);
190
+ const priceMicros = asNumber(g(57, 0, 0, 0, 0, 1, 0, 0));
191
+ const originalPriceMicros = asNumber(g(57, 0, 0, 0, 0, 1, 1, 0));
192
+ const developerUrl = asString(g(68, 1, 4, 2));
193
+ const developerId = developerUrl?.split("id=")[1];
194
+ const androidVersionRaw = asString(gWithFallback([140, 1, 1, 0, 0, 1], [-1, "141", 1, 1, 0, 0, 1]));
195
+ const androidMaxVersionRaw = asString(
196
+ gWithFallback([140, 1, 1, 0, 1, 1], [-1, "141", 1, 1, 0, 1, 1])
197
+ );
198
+ const updatedSeconds = asNumber(gWithFallback([145, 0, 1, 0], [-1, "146", 0, 1, 0]));
199
+ const earlyAccessValue = g(18, 2);
200
+ return {
201
+ appId,
202
+ url,
203
+ title,
204
+ description: htmlToText(descriptionHTML),
205
+ descriptionHTML,
206
+ summary: asString(g(73, 0, 1)),
207
+ installs: asString(g(13, 0)),
208
+ minInstalls: asNumber(g(13, 1)),
209
+ maxInstalls: asNumber(g(13, 2)),
210
+ score: asNumber(g(51, 0, 1)),
211
+ scoreText: asString(g(51, 0, 0)),
212
+ ratings: asNumber(g(51, 2, 1)),
213
+ reviews: asNumber(g(51, 3, 1)),
214
+ histogram: buildHistogram(g(51, 1)),
215
+ price: priceMicros !== void 0 ? priceMicros / 1e6 : 0,
216
+ originalPrice: originalPriceMicros !== void 0 ? originalPriceMicros / 1e6 : void 0,
217
+ discountEndDate: asString(g(57, 0, 0, 0, 0, 14, 1)),
218
+ free: priceMicros === 0,
219
+ currency: asString(g(57, 0, 0, 0, 0, 1, 0, 1)),
220
+ priceText: asString(g(57, 0, 0, 0, 0, 1, 0, 2)) || "Free",
221
+ available: Boolean(g(18, 0)),
222
+ offersIAP: Boolean(g(19, 0)),
223
+ IAPRange: asString(g(19, 0)),
224
+ androidVersion: normalizeAndroidVersion(androidVersionRaw),
225
+ androidVersionText: androidVersionRaw ?? "Varies with device",
226
+ androidMaxVersion: normalizeAndroidVersion(androidMaxVersionRaw),
227
+ developer: asString(g(68, 0)),
228
+ developerId,
229
+ developerEmail: asString(g(69, 1, 0)),
230
+ developerWebsite: asString(g(69, 0, 5, 2)),
231
+ developerAddress: asString(g(69, 2, 0)),
232
+ developerLegalName: asString(g(69, 4, 0)),
233
+ developerLegalEmail: asString(g(69, 4, 1, 0)),
234
+ developerLegalAddress: asString(g(69, 4, 2, 0))?.replace(/\n/g, ", "),
235
+ developerLegalPhoneNumber: asString(g(69, 4, 3)),
236
+ developerInternalID: developerId,
237
+ privacyPolicy: asString(g(99, 0, 5, 2)),
238
+ genre: asString(g(79, 0, 0, 0)),
239
+ genreId: asString(g(79, 0, 0, 2)),
240
+ categories: extractCategoriesWithGenreFallback(base),
241
+ icon: asString(g(95, 0, 3, 2)),
242
+ headerImage: asString(g(96, 0, 3, 2)),
243
+ screenshots: extractScreenshots(g(78, 0)),
244
+ video: asString(g(100, 0, 0, 3, 2)),
245
+ videoImage: asString(g(100, 1, 0, 3, 2)),
246
+ previewVideo: asString(g(100, 1, 2, 0, 2)),
247
+ contentRating: asString(g(9, 0)),
248
+ contentRatingDescription: asString(g(9, 2, 1)),
249
+ adSupported: Boolean(g(48)),
250
+ released: asString(g(10, 0)),
251
+ updated: updatedSeconds !== void 0 ? updatedSeconds * 1e3 : void 0,
252
+ version: asString(gWithFallback([140, 0, 0, 0], [-1, "141", 0, 0, 0])) || "VARY",
253
+ recentChanges: asString(gWithFallback([144, 1, 1], [-1, "145", 1, 1])),
254
+ comments: extractComments(parsed),
255
+ preregister: g(18, 0) === 1,
256
+ earlyAccessEnabled: typeof earlyAccessValue === "string",
257
+ isAvailableInPlayPass: Boolean(g(62))
258
+ };
259
+ }
260
+ function extractDescriptionHtml(base) {
261
+ const translated = asString(pathGet(base, [12, 0, 0, 1]));
262
+ const original = asString(pathGet(base, [72, 0, 1]));
263
+ const description = translated || original;
264
+ if (description === void 0) {
265
+ throw new PlayScraperParseError(
266
+ "App description not found",
267
+ "checked ds:5[1][2][12][0][0][1] and ds:5[1][2][72][0][1]"
268
+ );
269
+ }
270
+ return description;
271
+ }
272
+ function normalizeAndroidVersion(versionText) {
273
+ if (!versionText) return "VARY";
274
+ const number = versionText.split(" ")[0];
275
+ if (number !== void 0 && Number.parseFloat(number)) {
276
+ return number;
277
+ }
278
+ return "VARY";
279
+ }
280
+ function buildHistogram(container) {
281
+ const empty = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
282
+ if (!Array.isArray(container)) return empty;
283
+ return {
284
+ 1: asNumber(pathGet(container, [1, 1])) ?? 0,
285
+ 2: asNumber(pathGet(container, [2, 1])) ?? 0,
286
+ 3: asNumber(pathGet(container, [3, 1])) ?? 0,
287
+ 4: asNumber(pathGet(container, [4, 1])) ?? 0,
288
+ 5: asNumber(pathGet(container, [5, 1])) ?? 0
289
+ };
290
+ }
291
+ function extractScreenshots(screenshots) {
292
+ const list = asArray(screenshots);
293
+ if (!list || list.length === 0) return [];
294
+ return list.map((screenshot) => asString(pathGet(screenshot, [3, 2]))).filter((screenshotUrl) => screenshotUrl !== void 0);
295
+ }
296
+ function extractCategoriesWithGenreFallback(base) {
297
+ const categories = extractCategories(pathGet(base, [118]), []);
298
+ if (categories.length === 0) {
299
+ categories.push({
300
+ name: asString(pathGet(base, [79, 0, 0, 0])),
301
+ id: asString(pathGet(base, [79, 0, 0, 2]))
302
+ });
303
+ }
304
+ return categories;
305
+ }
306
+ function extractCategories(searchValue, categories) {
307
+ if (!Array.isArray(searchValue) || searchValue.length === 0) return categories;
308
+ if (searchValue.length >= 4 && typeof searchValue[0] === "string") {
309
+ categories.push({
310
+ name: searchValue[0],
311
+ id: asString(searchValue[2])
312
+ });
313
+ } else {
314
+ for (const sub of searchValue) {
315
+ extractCategories(sub, categories);
316
+ }
317
+ }
318
+ return categories;
319
+ }
320
+ function extractComments(parsed) {
321
+ for (const datasetKey of ["ds:8", "ds:9"]) {
322
+ const hasAuthor = pathGet(parsed, [datasetKey, 0, 0, 1, 0]);
323
+ const hasVersion = pathGet(parsed, [datasetKey, 0, 0, 10]);
324
+ const hasDate = pathGet(parsed, [datasetKey, 0, 0, 5, 0]);
325
+ if (hasAuthor && hasVersion && hasDate) {
326
+ const comments = asArray(pathGet(parsed, [datasetKey, 0])) ?? [];
327
+ return comments.slice(0, 5).map((comment) => asString(pathGet(comment, [4])));
328
+ }
329
+ }
330
+ return [];
331
+ }
332
+
333
+ // src/reviews.ts
334
+ var REVIEWS_PER_REQUEST = 150;
335
+ async function reviews(options) {
336
+ if (!options || !options.appId) {
337
+ throw new PlayScraperParseError("appId missing", "reviews() requires an appId option");
338
+ }
339
+ const sortValue = options.sort ?? sort.NEWEST;
340
+ if (!Object.values(sort).includes(sortValue)) {
341
+ throw new PlayScraperParseError(
342
+ `Invalid sort ${String(sortValue)}`,
343
+ `valid values: ${Object.values(sort).join(", ")}`
344
+ );
345
+ }
346
+ const resolved = {
347
+ appId: options.appId,
348
+ lang: options.lang ?? "en",
349
+ country: options.country ?? "us",
350
+ sort: sortValue,
351
+ num: options.num ?? REVIEWS_PER_REQUEST,
352
+ paginate: options.paginate ?? false,
353
+ fetchFn: options.fetch
354
+ };
355
+ const accumulated = [];
356
+ let token = options.nextPaginationToken ?? null;
357
+ let cookieHeader;
358
+ for (; ; ) {
359
+ const requestResult = await makeReviewsRequest(resolved, token, cookieHeader);
360
+ cookieHeader = requestResult.cookieHeader ?? cookieHeader;
361
+ accumulated.push(...requestResult.reviews);
362
+ token = requestResult.token;
363
+ const shouldContinue = !resolved.paginate && token !== null && accumulated.length < resolved.num;
364
+ if (!shouldContinue) break;
365
+ }
366
+ return {
367
+ data: accumulated.length > resolved.num ? accumulated.slice(0, resolved.num) : accumulated,
368
+ nextPaginationToken: token
369
+ };
370
+ }
371
+ async function makeReviewsRequest(options, token, cookieHeader) {
372
+ const url = `${BASE_URL}/_/PlayStoreUi/data/batchexecute?rpcids=qnKhOb&f.sid=-697906427155521722&bl=boq_playuiserver_20190903.08_p0&hl=${encodeURIComponent(options.lang)}&gl=${encodeURIComponent(options.country)}&authuser&soc-app=121&soc-platform=1&soc-device=1&_reqid=1065213`;
373
+ const headers = {
374
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
375
+ };
376
+ if (cookieHeader !== void 0) {
377
+ headers.Cookie = cookieHeader;
378
+ }
379
+ const { body, setCookies } = await httpRequest(url, {
380
+ method: "POST",
381
+ body: buildRequestBody(options.appId, options.sort, token),
382
+ headers,
383
+ fetch: options.fetchFn
384
+ });
385
+ const payload = parseBatchExecuteResponse(body);
386
+ const newCookieHeader = cookieHeaderFromSetCookies(setCookies);
387
+ if (payload === null) {
388
+ return { reviews: [], token: null, cookieHeader: newCookieHeader };
389
+ }
390
+ return {
391
+ reviews: extractReviews(payload, options.appId),
392
+ token: asString(pathGet(payload, [1, 1])) ?? null,
393
+ cookieHeader: newCookieHeader
394
+ };
395
+ }
396
+ function buildRequestBody(appId, sortValue, token) {
397
+ const pageSpec = [REVIEWS_PER_REQUEST, null, token];
398
+ const innerRequest = JSON.stringify([null, null, [2, sortValue, pageSpec, null, []], [appId, 7]]);
399
+ const envelope = JSON.stringify([[["UsvDTd", innerRequest, null, "generic"]]]);
400
+ return `f.req=${encodeURIComponent(envelope)}`;
401
+ }
402
+ function parseBatchExecuteResponse(body) {
403
+ const withoutPrefix = body.startsWith(")]}'") ? body.slice(4) : body;
404
+ let envelope;
405
+ try {
406
+ envelope = JSON.parse(withoutPrefix);
407
+ } catch {
408
+ throw new PlayScraperParseError(
409
+ "batchexecute response is not valid JSON",
410
+ snippet(withoutPrefix)
411
+ );
412
+ }
413
+ const inner = pathGet(envelope, [0, 2]);
414
+ if (inner === null || inner === void 0) {
415
+ return null;
416
+ }
417
+ if (typeof inner !== "string") {
418
+ throw new PlayScraperParseError(
419
+ "batchexecute envelope[0][2] is not a string",
420
+ snippet(inner)
421
+ );
422
+ }
423
+ try {
424
+ return JSON.parse(inner);
425
+ } catch {
426
+ throw new PlayScraperParseError(
427
+ "batchexecute inner payload is not valid JSON",
428
+ snippet(inner)
429
+ );
430
+ }
431
+ }
432
+ function extractReviews(payload, appId) {
433
+ const rawReviews = pathGet(payload, [0]);
434
+ if (rawReviews === null || rawReviews === void 0) {
435
+ return [];
436
+ }
437
+ const reviewList = asArray(rawReviews);
438
+ if (!reviewList) {
439
+ throw new PlayScraperParseError(
440
+ "Reviews payload[0] is not an array",
441
+ snippet(rawReviews)
442
+ );
443
+ }
444
+ return reviewList.map((raw) => extractReview(raw, appId));
445
+ }
446
+ function extractReview(raw, appId) {
447
+ const id = asString(pathGet(raw, [0]));
448
+ if (id === void 0) {
449
+ throw new PlayScraperParseError("Review id not found at [0]", snippet(raw));
450
+ }
451
+ const score = asNumber(pathGet(raw, [2]));
452
+ const criteriaList = asArray(pathGet(raw, [12, 0])) ?? [];
453
+ return {
454
+ id,
455
+ userName: asString(pathGet(raw, [1, 0])),
456
+ userImage: asString(pathGet(raw, [1, 1, 3, 2])),
457
+ date: generateDate(pathGet(raw, [5])),
458
+ score,
459
+ scoreText: String(score),
460
+ url: `${BASE_URL}/store/apps/details?id=${appId}&reviewId=${id}`,
461
+ title: null,
462
+ text: asString(pathGet(raw, [4])),
463
+ replyDate: generateDate(pathGet(raw, [7, 2])),
464
+ replyText: asString(pathGet(raw, [7, 1])) || null,
465
+ version: asString(pathGet(raw, [10])) || null,
466
+ thumbsUp: asNumber(pathGet(raw, [6])),
467
+ criterias: criteriaList.map(buildCriteria)
468
+ };
469
+ }
470
+ function buildCriteria(raw) {
471
+ const ratingContainer = pathGet(raw, [1]);
472
+ return {
473
+ criteria: asString(pathGet(raw, [0])),
474
+ rating: ratingContainer ? asNumber(pathGet(ratingContainer, [0])) ?? null : null
475
+ };
476
+ }
477
+ function generateDate(dateValue) {
478
+ const dateArray = asArray(dateValue);
479
+ if (!dateArray) return null;
480
+ const seconds = dateArray[0];
481
+ if (typeof seconds !== "number") return null;
482
+ const subsecond = dateArray[1];
483
+ const millisecondsLastDigits = String(subsecond || "000");
484
+ const milliseconds = Number(`${seconds}${millisecondsLastDigits.substring(0, 3)}`);
485
+ const date = new Date(milliseconds);
486
+ return Number.isNaN(date.getTime()) ? null : date.toJSON();
487
+ }
488
+ export {
489
+ BASE_URL,
490
+ PlayScraperError,
491
+ PlayScraperHttpError,
492
+ PlayScraperParseError,
493
+ app,
494
+ reviews,
495
+ sort
496
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "google-play-scraper-fetch",
3
+ "version": "0.1.0",
4
+ "description": "Fetch-only port of google-play-scraper (reviews and app details). Zero dependencies. Runs on Cloudflare Workers, Deno, Bun, Node, and browsers.",
5
+ "keywords": [
6
+ "google play",
7
+ "play store",
8
+ "scraper",
9
+ "reviews",
10
+ "app",
11
+ "fetch",
12
+ "cloudflare workers",
13
+ "deno",
14
+ "bun"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/usero-feedback/google-play-scraper-fetch.git"
20
+ },
21
+ "homepage": "https://github.com/usero-feedback/google-play-scraper-fetch#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/usero-feedback/google-play-scraper-fetch/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "./dist/index.cjs",
27
+ "module": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "require": "./dist/index.cjs"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "sideEffects": false,
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run",
47
+ "test:live": "LIVE=1 vitest run"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^25.9.3",
51
+ "tsup": "^8.3.5",
52
+ "typescript": "^5.7.2",
53
+ "vitest": "^3.0.0"
54
+ }
55
+ }