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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Usero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # google-play-scraper-fetch
2
+
3
+ Fetch-only port of [google-play-scraper](https://github.com/facundoolano/google-play-scraper) covering `app()` and `reviews()`. Zero runtime dependencies. Runs on Cloudflare Workers, Deno, Bun, Node 18+, and browsers.
4
+
5
+ ## Why this exists
6
+
7
+ The original google-play-scraper depends on `got`, `cheerio`, and Node streams, so it only runs on Node. This package reimplements the two functions most apps need (app metadata and reviews) on top of the global `fetch` API, with the same request shapes and response field mappings. If your code runs on a Cloudflare Worker or an edge runtime, this works where the original cannot.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install google-play-scraper-fetch
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### App details
18
+
19
+ ```ts
20
+ import { app } from 'google-play-scraper-fetch'
21
+
22
+ const details = await app({ appId: 'com.pocketguard.android.app' })
23
+ console.log(details.title, details.score, details.icon)
24
+ ```
25
+
26
+ Options: `appId` (required), `lang` (default `en`), `country` (default `us`), `fetch` (custom fetch implementation).
27
+
28
+ ### Reviews
29
+
30
+ ```ts
31
+ import { reviews, sort } from 'google-play-scraper-fetch'
32
+
33
+ // First page
34
+ const page1 = await reviews({
35
+ appId: 'com.pocketguard.android.app',
36
+ sort: sort.NEWEST,
37
+ num: 50,
38
+ paginate: true,
39
+ })
40
+ console.log(page1.data.length, 'reviews')
41
+
42
+ // Next page
43
+ const page2 = await reviews({
44
+ appId: 'com.pocketguard.android.app',
45
+ sort: sort.NEWEST,
46
+ paginate: true,
47
+ nextPaginationToken: page1.nextPaginationToken,
48
+ })
49
+ ```
50
+
51
+ Options: `appId` (required), `lang`, `country`, `sort` (`sort.NEWEST`, `sort.RATING`, `sort.HELPFULNESS`), `num` (default 150), `paginate` (default false, set true to page manually), `nextPaginationToken`, `fetch`.
52
+
53
+ With `paginate: false`, the library keeps requesting pages internally until it has `num` reviews or runs out. Each review includes `id`, `userName`, `date` (ISO string), `score`, `text`, `replyText`, `version`, `thumbsUp`, and `criterias`.
54
+
55
+ ## Errors
56
+
57
+ Everything thrown by this package extends `PlayScraperError`:
58
+
59
+ - `PlayScraperHttpError` for failed requests (includes `status` and `url`, 404 means the app does not exist)
60
+ - `PlayScraperParseError` when the response cannot be parsed into the expected shape (includes a `context` snippet to help diagnose)
61
+
62
+ The package throws rather than returning partial or garbage data, so wrap calls in try/catch and treat `PlayScraperParseError` as a signal that the protocol may have changed.
63
+
64
+ ## Caveat
65
+
66
+ Google Play has no public API for this data. Both functions wrap an internal, undocumented protocol (`batchexecute` and the script payloads embedded in the details page). Google can change it at any time without notice. Field mappings are ported from google-play-scraper and verified against captured fixtures, but expect occasional breakage and pin your version.
67
+
68
+ ## Differences from the original
69
+
70
+ - Only `app()` and `reviews()` are implemented.
71
+ - No throttling option. Rate-limit in your own code if you need it.
72
+ - No `requestOptions` passthrough. Pass a custom `fetch` instead.
73
+ - No persistent cookie jar. Cookies from the first reviews response are reused for follow-up pages within a single `reviews()` call.
74
+ - `memoized()` is not implemented.
75
+
76
+ ## License
77
+
78
+ MIT
79
+
80
+ ---
81
+
82
+ Want these reviews clustered and turned into shipped fixes automatically? https://usero.io
package/dist/index.cjs ADDED
@@ -0,0 +1,529 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ BASE_URL: () => BASE_URL,
24
+ PlayScraperError: () => PlayScraperError,
25
+ PlayScraperHttpError: () => PlayScraperHttpError,
26
+ PlayScraperParseError: () => PlayScraperParseError,
27
+ app: () => app,
28
+ reviews: () => reviews,
29
+ sort: () => sort
30
+ });
31
+ module.exports = __toCommonJS(index_exports);
32
+
33
+ // src/constants.ts
34
+ var BASE_URL = "https://play.google.com";
35
+ var sort = {
36
+ NEWEST: 2,
37
+ RATING: 3,
38
+ HELPFULNESS: 1
39
+ };
40
+
41
+ // src/errors.ts
42
+ var PlayScraperError = class extends Error {
43
+ constructor(message) {
44
+ super(message);
45
+ this.name = "PlayScraperError";
46
+ }
47
+ };
48
+ var PlayScraperHttpError = class extends PlayScraperError {
49
+ status;
50
+ url;
51
+ constructor(message, status, url) {
52
+ super(message);
53
+ this.name = "PlayScraperHttpError";
54
+ this.status = status;
55
+ this.url = url;
56
+ }
57
+ };
58
+ var PlayScraperParseError = class extends PlayScraperError {
59
+ context;
60
+ constructor(message, context) {
61
+ super(`${message} (${context})`);
62
+ this.name = "PlayScraperParseError";
63
+ this.context = context;
64
+ }
65
+ };
66
+ function snippet(value, max = 200) {
67
+ let text;
68
+ try {
69
+ text = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
70
+ } catch {
71
+ text = String(value);
72
+ }
73
+ return text.length > max ? `${text.slice(0, max)}...` : text;
74
+ }
75
+
76
+ // src/htmlToText.ts
77
+ var NAMED_ENTITIES = {
78
+ amp: "&",
79
+ lt: "<",
80
+ gt: ">",
81
+ quot: '"',
82
+ apos: "'",
83
+ nbsp: "\xA0"
84
+ };
85
+ function htmlToText(html) {
86
+ return html.replace(/<br\s*\/?>/gi, "\r\n").replace(/<[^>]+>/g, "").replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (full, entity) => {
87
+ if (entity.startsWith("#x") || entity.startsWith("#X")) {
88
+ const code = Number.parseInt(entity.slice(2), 16);
89
+ return Number.isNaN(code) ? full : String.fromCodePoint(code);
90
+ }
91
+ if (entity.startsWith("#")) {
92
+ const code = Number.parseInt(entity.slice(1), 10);
93
+ return Number.isNaN(code) ? full : String.fromCodePoint(code);
94
+ }
95
+ return NAMED_ENTITIES[entity] ?? full;
96
+ });
97
+ }
98
+
99
+ // src/http.ts
100
+ async function httpRequest(url, options = {}) {
101
+ const fetchFn = options.fetch ?? fetch;
102
+ const response = await fetchFn(url, {
103
+ method: options.method ?? "GET",
104
+ body: options.body,
105
+ headers: options.headers,
106
+ redirect: "follow"
107
+ });
108
+ if (!response.ok) {
109
+ const message = response.status === 404 ? "App not found (404)" : `Error requesting Google Play: HTTP ${response.status}`;
110
+ throw new PlayScraperHttpError(message, response.status, url);
111
+ }
112
+ const setCookies = extractSetCookies(response.headers);
113
+ const body = await response.text();
114
+ return { body, setCookies };
115
+ }
116
+ function extractSetCookies(headers) {
117
+ const headersWithGetSetCookie = headers;
118
+ if (typeof headersWithGetSetCookie.getSetCookie === "function") {
119
+ return headersWithGetSetCookie.getSetCookie();
120
+ }
121
+ const single = headers.get("set-cookie");
122
+ return single === null ? [] : [single];
123
+ }
124
+ function cookieHeaderFromSetCookies(setCookies) {
125
+ const pairs = setCookies.map((cookieLine) => cookieLine.split(";", 1)[0]?.trim()).filter((pair) => pair !== void 0 && pair.includes("="));
126
+ return pairs.length > 0 ? pairs.join("; ") : void 0;
127
+ }
128
+
129
+ // src/path.ts
130
+ function pathGet(data, path) {
131
+ let current = data;
132
+ for (const key of path) {
133
+ if (current === null || current === void 0) return void 0;
134
+ if (typeof key === "number" && Array.isArray(current)) {
135
+ current = key < 0 ? current[current.length + key] : current[key];
136
+ continue;
137
+ }
138
+ if (typeof current === "object") {
139
+ current = current[String(key)];
140
+ continue;
141
+ }
142
+ return void 0;
143
+ }
144
+ return current;
145
+ }
146
+ function asString(value) {
147
+ return typeof value === "string" ? value : void 0;
148
+ }
149
+ function asNumber(value) {
150
+ return typeof value === "number" ? value : void 0;
151
+ }
152
+ function asArray(value) {
153
+ return Array.isArray(value) ? value : void 0;
154
+ }
155
+
156
+ // src/scriptData.ts
157
+ function parseScriptData(html) {
158
+ const scriptRegex = />AF_initDataCallback[\s\S]*?<\/script/g;
159
+ const keyRegex = /(ds:.*?)'/;
160
+ const valueRegex = /data:([\s\S]*?), sideChannel: {}}\);<\//;
161
+ const matches = html.match(scriptRegex);
162
+ if (!matches) {
163
+ throw new PlayScraperParseError(
164
+ "No AF_initDataCallback script tags found in the page",
165
+ `html starts with: ${snippet(html, 120)}`
166
+ );
167
+ }
168
+ const parsed = {};
169
+ for (const match of matches) {
170
+ const keyMatch = match.match(keyRegex);
171
+ const valueMatch = match.match(valueRegex);
172
+ if (!keyMatch || !valueMatch) continue;
173
+ const key = keyMatch[1];
174
+ const rawValue = valueMatch[1];
175
+ if (key === void 0 || rawValue === void 0) continue;
176
+ try {
177
+ parsed[key] = JSON.parse(rawValue);
178
+ } catch (error) {
179
+ throw new PlayScraperParseError(
180
+ `Failed to JSON.parse AF_initDataCallback payload for ${key}`,
181
+ snippet(rawValue)
182
+ );
183
+ }
184
+ }
185
+ return parsed;
186
+ }
187
+
188
+ // src/app.ts
189
+ async function app(options) {
190
+ if (!options || !options.appId) {
191
+ throw new PlayScraperParseError("appId missing", "app() requires an appId option");
192
+ }
193
+ const lang = options.lang ?? "en";
194
+ const country = options.country ?? "us";
195
+ const params = new URLSearchParams({ id: options.appId, hl: lang, gl: country });
196
+ const url = `${BASE_URL}/store/apps/details?${params.toString()}`;
197
+ const { body } = await httpRequest(url, { fetch: options.fetch });
198
+ const parsed = parseScriptData(body);
199
+ return extractAppDetails(parsed, options.appId, url);
200
+ }
201
+ function extractAppDetails(parsed, appId, url) {
202
+ const base = pathGet(parsed, ["ds:5", 1, 2]);
203
+ if (base === void 0 || base === null) {
204
+ throw new PlayScraperParseError(
205
+ "App data not found at ds:5[1][2]",
206
+ `available datasets: ${snippet(Object.keys(parsed))}`
207
+ );
208
+ }
209
+ const g = (...path) => pathGet(base, path);
210
+ const gWithFallback = (path, fallbackPath) => {
211
+ const primary = pathGet(base, path);
212
+ return primary === null || primary === void 0 ? pathGet(base, fallbackPath) : primary;
213
+ };
214
+ const title = asString(g(0, 0));
215
+ if (title === void 0) {
216
+ throw new PlayScraperParseError(
217
+ "App title not found at ds:5[1][2][0][0]",
218
+ `value: ${snippet(g(0, 0))}`
219
+ );
220
+ }
221
+ const descriptionHTML = extractDescriptionHtml(base);
222
+ const priceMicros = asNumber(g(57, 0, 0, 0, 0, 1, 0, 0));
223
+ const originalPriceMicros = asNumber(g(57, 0, 0, 0, 0, 1, 1, 0));
224
+ const developerUrl = asString(g(68, 1, 4, 2));
225
+ const developerId = developerUrl?.split("id=")[1];
226
+ const androidVersionRaw = asString(gWithFallback([140, 1, 1, 0, 0, 1], [-1, "141", 1, 1, 0, 0, 1]));
227
+ const androidMaxVersionRaw = asString(
228
+ gWithFallback([140, 1, 1, 0, 1, 1], [-1, "141", 1, 1, 0, 1, 1])
229
+ );
230
+ const updatedSeconds = asNumber(gWithFallback([145, 0, 1, 0], [-1, "146", 0, 1, 0]));
231
+ const earlyAccessValue = g(18, 2);
232
+ return {
233
+ appId,
234
+ url,
235
+ title,
236
+ description: htmlToText(descriptionHTML),
237
+ descriptionHTML,
238
+ summary: asString(g(73, 0, 1)),
239
+ installs: asString(g(13, 0)),
240
+ minInstalls: asNumber(g(13, 1)),
241
+ maxInstalls: asNumber(g(13, 2)),
242
+ score: asNumber(g(51, 0, 1)),
243
+ scoreText: asString(g(51, 0, 0)),
244
+ ratings: asNumber(g(51, 2, 1)),
245
+ reviews: asNumber(g(51, 3, 1)),
246
+ histogram: buildHistogram(g(51, 1)),
247
+ price: priceMicros !== void 0 ? priceMicros / 1e6 : 0,
248
+ originalPrice: originalPriceMicros !== void 0 ? originalPriceMicros / 1e6 : void 0,
249
+ discountEndDate: asString(g(57, 0, 0, 0, 0, 14, 1)),
250
+ free: priceMicros === 0,
251
+ currency: asString(g(57, 0, 0, 0, 0, 1, 0, 1)),
252
+ priceText: asString(g(57, 0, 0, 0, 0, 1, 0, 2)) || "Free",
253
+ available: Boolean(g(18, 0)),
254
+ offersIAP: Boolean(g(19, 0)),
255
+ IAPRange: asString(g(19, 0)),
256
+ androidVersion: normalizeAndroidVersion(androidVersionRaw),
257
+ androidVersionText: androidVersionRaw ?? "Varies with device",
258
+ androidMaxVersion: normalizeAndroidVersion(androidMaxVersionRaw),
259
+ developer: asString(g(68, 0)),
260
+ developerId,
261
+ developerEmail: asString(g(69, 1, 0)),
262
+ developerWebsite: asString(g(69, 0, 5, 2)),
263
+ developerAddress: asString(g(69, 2, 0)),
264
+ developerLegalName: asString(g(69, 4, 0)),
265
+ developerLegalEmail: asString(g(69, 4, 1, 0)),
266
+ developerLegalAddress: asString(g(69, 4, 2, 0))?.replace(/\n/g, ", "),
267
+ developerLegalPhoneNumber: asString(g(69, 4, 3)),
268
+ developerInternalID: developerId,
269
+ privacyPolicy: asString(g(99, 0, 5, 2)),
270
+ genre: asString(g(79, 0, 0, 0)),
271
+ genreId: asString(g(79, 0, 0, 2)),
272
+ categories: extractCategoriesWithGenreFallback(base),
273
+ icon: asString(g(95, 0, 3, 2)),
274
+ headerImage: asString(g(96, 0, 3, 2)),
275
+ screenshots: extractScreenshots(g(78, 0)),
276
+ video: asString(g(100, 0, 0, 3, 2)),
277
+ videoImage: asString(g(100, 1, 0, 3, 2)),
278
+ previewVideo: asString(g(100, 1, 2, 0, 2)),
279
+ contentRating: asString(g(9, 0)),
280
+ contentRatingDescription: asString(g(9, 2, 1)),
281
+ adSupported: Boolean(g(48)),
282
+ released: asString(g(10, 0)),
283
+ updated: updatedSeconds !== void 0 ? updatedSeconds * 1e3 : void 0,
284
+ version: asString(gWithFallback([140, 0, 0, 0], [-1, "141", 0, 0, 0])) || "VARY",
285
+ recentChanges: asString(gWithFallback([144, 1, 1], [-1, "145", 1, 1])),
286
+ comments: extractComments(parsed),
287
+ preregister: g(18, 0) === 1,
288
+ earlyAccessEnabled: typeof earlyAccessValue === "string",
289
+ isAvailableInPlayPass: Boolean(g(62))
290
+ };
291
+ }
292
+ function extractDescriptionHtml(base) {
293
+ const translated = asString(pathGet(base, [12, 0, 0, 1]));
294
+ const original = asString(pathGet(base, [72, 0, 1]));
295
+ const description = translated || original;
296
+ if (description === void 0) {
297
+ throw new PlayScraperParseError(
298
+ "App description not found",
299
+ "checked ds:5[1][2][12][0][0][1] and ds:5[1][2][72][0][1]"
300
+ );
301
+ }
302
+ return description;
303
+ }
304
+ function normalizeAndroidVersion(versionText) {
305
+ if (!versionText) return "VARY";
306
+ const number = versionText.split(" ")[0];
307
+ if (number !== void 0 && Number.parseFloat(number)) {
308
+ return number;
309
+ }
310
+ return "VARY";
311
+ }
312
+ function buildHistogram(container) {
313
+ const empty = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
314
+ if (!Array.isArray(container)) return empty;
315
+ return {
316
+ 1: asNumber(pathGet(container, [1, 1])) ?? 0,
317
+ 2: asNumber(pathGet(container, [2, 1])) ?? 0,
318
+ 3: asNumber(pathGet(container, [3, 1])) ?? 0,
319
+ 4: asNumber(pathGet(container, [4, 1])) ?? 0,
320
+ 5: asNumber(pathGet(container, [5, 1])) ?? 0
321
+ };
322
+ }
323
+ function extractScreenshots(screenshots) {
324
+ const list = asArray(screenshots);
325
+ if (!list || list.length === 0) return [];
326
+ return list.map((screenshot) => asString(pathGet(screenshot, [3, 2]))).filter((screenshotUrl) => screenshotUrl !== void 0);
327
+ }
328
+ function extractCategoriesWithGenreFallback(base) {
329
+ const categories = extractCategories(pathGet(base, [118]), []);
330
+ if (categories.length === 0) {
331
+ categories.push({
332
+ name: asString(pathGet(base, [79, 0, 0, 0])),
333
+ id: asString(pathGet(base, [79, 0, 0, 2]))
334
+ });
335
+ }
336
+ return categories;
337
+ }
338
+ function extractCategories(searchValue, categories) {
339
+ if (!Array.isArray(searchValue) || searchValue.length === 0) return categories;
340
+ if (searchValue.length >= 4 && typeof searchValue[0] === "string") {
341
+ categories.push({
342
+ name: searchValue[0],
343
+ id: asString(searchValue[2])
344
+ });
345
+ } else {
346
+ for (const sub of searchValue) {
347
+ extractCategories(sub, categories);
348
+ }
349
+ }
350
+ return categories;
351
+ }
352
+ function extractComments(parsed) {
353
+ for (const datasetKey of ["ds:8", "ds:9"]) {
354
+ const hasAuthor = pathGet(parsed, [datasetKey, 0, 0, 1, 0]);
355
+ const hasVersion = pathGet(parsed, [datasetKey, 0, 0, 10]);
356
+ const hasDate = pathGet(parsed, [datasetKey, 0, 0, 5, 0]);
357
+ if (hasAuthor && hasVersion && hasDate) {
358
+ const comments = asArray(pathGet(parsed, [datasetKey, 0])) ?? [];
359
+ return comments.slice(0, 5).map((comment) => asString(pathGet(comment, [4])));
360
+ }
361
+ }
362
+ return [];
363
+ }
364
+
365
+ // src/reviews.ts
366
+ var REVIEWS_PER_REQUEST = 150;
367
+ async function reviews(options) {
368
+ if (!options || !options.appId) {
369
+ throw new PlayScraperParseError("appId missing", "reviews() requires an appId option");
370
+ }
371
+ const sortValue = options.sort ?? sort.NEWEST;
372
+ if (!Object.values(sort).includes(sortValue)) {
373
+ throw new PlayScraperParseError(
374
+ `Invalid sort ${String(sortValue)}`,
375
+ `valid values: ${Object.values(sort).join(", ")}`
376
+ );
377
+ }
378
+ const resolved = {
379
+ appId: options.appId,
380
+ lang: options.lang ?? "en",
381
+ country: options.country ?? "us",
382
+ sort: sortValue,
383
+ num: options.num ?? REVIEWS_PER_REQUEST,
384
+ paginate: options.paginate ?? false,
385
+ fetchFn: options.fetch
386
+ };
387
+ const accumulated = [];
388
+ let token = options.nextPaginationToken ?? null;
389
+ let cookieHeader;
390
+ for (; ; ) {
391
+ const requestResult = await makeReviewsRequest(resolved, token, cookieHeader);
392
+ cookieHeader = requestResult.cookieHeader ?? cookieHeader;
393
+ accumulated.push(...requestResult.reviews);
394
+ token = requestResult.token;
395
+ const shouldContinue = !resolved.paginate && token !== null && accumulated.length < resolved.num;
396
+ if (!shouldContinue) break;
397
+ }
398
+ return {
399
+ data: accumulated.length > resolved.num ? accumulated.slice(0, resolved.num) : accumulated,
400
+ nextPaginationToken: token
401
+ };
402
+ }
403
+ async function makeReviewsRequest(options, token, cookieHeader) {
404
+ 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`;
405
+ const headers = {
406
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
407
+ };
408
+ if (cookieHeader !== void 0) {
409
+ headers.Cookie = cookieHeader;
410
+ }
411
+ const { body, setCookies } = await httpRequest(url, {
412
+ method: "POST",
413
+ body: buildRequestBody(options.appId, options.sort, token),
414
+ headers,
415
+ fetch: options.fetchFn
416
+ });
417
+ const payload = parseBatchExecuteResponse(body);
418
+ const newCookieHeader = cookieHeaderFromSetCookies(setCookies);
419
+ if (payload === null) {
420
+ return { reviews: [], token: null, cookieHeader: newCookieHeader };
421
+ }
422
+ return {
423
+ reviews: extractReviews(payload, options.appId),
424
+ token: asString(pathGet(payload, [1, 1])) ?? null,
425
+ cookieHeader: newCookieHeader
426
+ };
427
+ }
428
+ function buildRequestBody(appId, sortValue, token) {
429
+ const pageSpec = [REVIEWS_PER_REQUEST, null, token];
430
+ const innerRequest = JSON.stringify([null, null, [2, sortValue, pageSpec, null, []], [appId, 7]]);
431
+ const envelope = JSON.stringify([[["UsvDTd", innerRequest, null, "generic"]]]);
432
+ return `f.req=${encodeURIComponent(envelope)}`;
433
+ }
434
+ function parseBatchExecuteResponse(body) {
435
+ const withoutPrefix = body.startsWith(")]}'") ? body.slice(4) : body;
436
+ let envelope;
437
+ try {
438
+ envelope = JSON.parse(withoutPrefix);
439
+ } catch {
440
+ throw new PlayScraperParseError(
441
+ "batchexecute response is not valid JSON",
442
+ snippet(withoutPrefix)
443
+ );
444
+ }
445
+ const inner = pathGet(envelope, [0, 2]);
446
+ if (inner === null || inner === void 0) {
447
+ return null;
448
+ }
449
+ if (typeof inner !== "string") {
450
+ throw new PlayScraperParseError(
451
+ "batchexecute envelope[0][2] is not a string",
452
+ snippet(inner)
453
+ );
454
+ }
455
+ try {
456
+ return JSON.parse(inner);
457
+ } catch {
458
+ throw new PlayScraperParseError(
459
+ "batchexecute inner payload is not valid JSON",
460
+ snippet(inner)
461
+ );
462
+ }
463
+ }
464
+ function extractReviews(payload, appId) {
465
+ const rawReviews = pathGet(payload, [0]);
466
+ if (rawReviews === null || rawReviews === void 0) {
467
+ return [];
468
+ }
469
+ const reviewList = asArray(rawReviews);
470
+ if (!reviewList) {
471
+ throw new PlayScraperParseError(
472
+ "Reviews payload[0] is not an array",
473
+ snippet(rawReviews)
474
+ );
475
+ }
476
+ return reviewList.map((raw) => extractReview(raw, appId));
477
+ }
478
+ function extractReview(raw, appId) {
479
+ const id = asString(pathGet(raw, [0]));
480
+ if (id === void 0) {
481
+ throw new PlayScraperParseError("Review id not found at [0]", snippet(raw));
482
+ }
483
+ const score = asNumber(pathGet(raw, [2]));
484
+ const criteriaList = asArray(pathGet(raw, [12, 0])) ?? [];
485
+ return {
486
+ id,
487
+ userName: asString(pathGet(raw, [1, 0])),
488
+ userImage: asString(pathGet(raw, [1, 1, 3, 2])),
489
+ date: generateDate(pathGet(raw, [5])),
490
+ score,
491
+ scoreText: String(score),
492
+ url: `${BASE_URL}/store/apps/details?id=${appId}&reviewId=${id}`,
493
+ title: null,
494
+ text: asString(pathGet(raw, [4])),
495
+ replyDate: generateDate(pathGet(raw, [7, 2])),
496
+ replyText: asString(pathGet(raw, [7, 1])) || null,
497
+ version: asString(pathGet(raw, [10])) || null,
498
+ thumbsUp: asNumber(pathGet(raw, [6])),
499
+ criterias: criteriaList.map(buildCriteria)
500
+ };
501
+ }
502
+ function buildCriteria(raw) {
503
+ const ratingContainer = pathGet(raw, [1]);
504
+ return {
505
+ criteria: asString(pathGet(raw, [0])),
506
+ rating: ratingContainer ? asNumber(pathGet(ratingContainer, [0])) ?? null : null
507
+ };
508
+ }
509
+ function generateDate(dateValue) {
510
+ const dateArray = asArray(dateValue);
511
+ if (!dateArray) return null;
512
+ const seconds = dateArray[0];
513
+ if (typeof seconds !== "number") return null;
514
+ const subsecond = dateArray[1];
515
+ const millisecondsLastDigits = String(subsecond || "000");
516
+ const milliseconds = Number(`${seconds}${millisecondsLastDigits.substring(0, 3)}`);
517
+ const date = new Date(milliseconds);
518
+ return Number.isNaN(date.getTime()) ? null : date.toJSON();
519
+ }
520
+ // Annotate the CommonJS export names for ESM import in node:
521
+ 0 && (module.exports = {
522
+ BASE_URL,
523
+ PlayScraperError,
524
+ PlayScraperHttpError,
525
+ PlayScraperParseError,
526
+ app,
527
+ reviews,
528
+ sort
529
+ });