324 | */
325 | export const dataSafetyLabelPurposes = [
326 | 'App functionality',
327 | 'Analytics',
328 | 'Developer communications',
329 | 'Advertising or marketing',
330 | 'Fraud prevention, security, and compliance',
331 | 'Personalization',
332 | 'Account management',
333 | ] as const;
334 | /**
335 | * A purpose for which data collection or sharing can be declared in a data safety label.
336 | */
337 | export type DataSafetyLabelPurpose = typeof dataSafetyLabelPurposes[number];
338 |
--------------------------------------------------------------------------------
/src/common/data-format.ts:
--------------------------------------------------------------------------------
1 | import { LanguageCode, CountryCode } from './consts';
2 | import type { DataTypeDeclaration, DataSafetyLabelSecurityPracticesDeclarations } from '../endpoints/data-safety';
3 |
4 | /** A group of related permissions the app has access to. */
5 | export type PermissionGroup = {
6 | /** A machine-readable ID for the group. */
7 | id?: string;
8 | /** The name/label of the group. */
9 | name?: string;
10 | /** The URL to the group's icon. */
11 | icon_url?: string;
12 | /** The detailed permissions in this group the app has access to. */
13 | permissions: string[];
14 | };
15 |
16 | /**
17 | * The full metadata that can theoretically be fetched for an app. The individual endpoints will only return a subset of
18 | * this, see {@link AppMetadata}.
19 | */
20 | export type AppMetadataFull = {
21 | /** The app's position in a list (top chart, search results). */
22 | position: number;
23 | /** The app's bundle ID. */
24 | app_id: string;
25 | /** A URL to the app's icon. */
26 | icon_url: string;
27 | /** URLs to screenshots of the app. */
28 | screenshot_urls: string[];
29 | /** The app's name. */
30 | name: string;
31 | /** The app's review rating. */
32 | rating: number | undefined;
33 | /** The number of each rating the app has received. */
34 | rating_counts: {
35 | /** The number of 1-star ratings. */
36 | 1: number;
37 | /** The number of 2-star ratings. */
38 | 2: number;
39 | /** The number of 3-star ratings. */
40 | 3: number;
41 | /** The number of 4-star ratings. */
42 | 4: number;
43 | /** The number of 5-star ratings. */
44 | 5: number;
45 | /** The total number of ratings. */
46 | total: number;
47 | };
48 | /** The app's main category. */
49 | category: string;
50 | /** The app's price. Can be undefined for pre-release apps. */
51 | price: string | undefined;
52 | /** A URL to the Play Store website to buy the app. */
53 | buy_url: string | undefined;
54 | /** The relative path of the app on the Play Store website. */
55 | store_path: string;
56 | /** A URL to a video trailer for the app. */
57 | trailer_url: string | undefined;
58 | /** The app's description. */
59 | description: string;
60 | /** The app's developer. */
61 | developer: string;
62 | /** The relative path of the developer's page on the Play Store website. */
63 | developer_path: string;
64 | /** The URL to the developer's website. */
65 | developer_website_url: string | undefined;
66 | /** The developer's email address. */
67 | developer_email: string;
68 | /** The developer's address. */
69 | developer_address: string | undefined;
70 | /** The URL to the app's privacy policy. */
71 | privacy_policy_url: string | undefined;
72 | /** An overview of the data that the app may share with other companies or organizations. */
73 | data_shared: DataTypeDeclaration[] | undefined;
74 | /** An overview of the data the app may collect. */
75 | data_collected: DataTypeDeclaration[] | undefined;
76 | /** An overview of the app's security practices. */
77 | security_practices: DataSafetyLabelSecurityPracticesDeclarations | undefined;
78 | /** The approximate download count of the app as a string, as displayed on the Play Store website. */
79 | downloads: string;
80 | /** The exact download count of the app. */
81 | downloads_exact: number;
82 | /** A URL to the app's cover image. */
83 | cover_image_url: string | undefined;
84 | /** The app's permissions, grouped by category. */
85 | permissions: PermissionGroup[];
86 | /** The date when the app was last updated. */
87 | updated_on: Date;
88 | /** The date when the app was first published. */
89 | released_on?: Date;
90 | /** The cost of in-app purchases for the app. */
91 | in_app_purchases?: string;
92 | /** The app's content rating. */
93 | content_rating?: {
94 | /** The label for the content rating. */
95 | label: string;
96 | /** The URL to an icon for the content rating. */
97 | icon_url: string;
98 | /** A description of interactive elements in the app. */
99 | interactive_elements?: string;
100 | };
101 | /** The app's placement on a top chart. */
102 | top_chart_placement?: {
103 | /** The label for the placement. */
104 | label: string;
105 | /** The app's position in the top chart. */
106 | placement: string;
107 | };
108 | /** A list of the app's categories and related search terms. */
109 | tags?: {
110 | /** A machine-readable ID for the tag. */
111 | id?: string;
112 | /** The name/label of the tag. */
113 | name: string;
114 | /** The relative path of the category/search page on the Play Store website. */
115 | path: string;
116 | };
117 | /** The app's version. */
118 | version?: string;
119 | /** The app's required version of Android. */
120 | requires_android?: { version: string; api_level: number };
121 | /** The company distributing the app on the Play Store. */
122 | offered_by: string;
123 | };
124 | /** A property that can be present in the metadata of an app. */
125 | export type AppMetadataProperty = keyof AppMetadataFull;
126 | /** The metadata for a single app. The available properties depend on which endpoint this was fetched from. */
127 | export type AppMetadata = Pick;
128 |
129 | export const formatCurrency = (
130 | value: number,
131 | currency: string,
132 | options: { language: LanguageCode; country: CountryCode }
133 | ) => new Intl.NumberFormat(`${options.language}-${options.country}`, { style: 'currency', currency }).format(value);
134 |
135 | const appMetadataProperties: Partial<
136 | Record any>
137 | > = {
138 | app_id: (d) => d[0][0],
139 | icon_url: (d) => d[1][3][2],
140 | screenshot_urls: (d) => d[2].map((s: any) => s[3][2]),
141 | name: (d) => d[3],
142 | rating: (d) => d[4][1],
143 | category: (d) => d[5],
144 | price: (d, o) => (d[8] ? formatCurrency(d[8]?.[1][0][0] / 1e6, d[8]?.[1][0][1], o) : undefined),
145 | buy_url: (d) => d[8]?.[6][5][2],
146 | store_path: (d) => d[10][4][2],
147 | trailer_url: (d) => d[12]?.[0][0][3][2],
148 | description: (d) => d[13][1],
149 | developer: (d) => d[14],
150 | downloads: (d) => d[15],
151 | cover_image_url: (d) => d[22][3]?.[2],
152 | };
153 | /** Parse an app entry in a search or top chart response. */
154 | export const parseAppEntry = (
155 | entry: any,
156 | properties: P[] | readonly P[],
157 | options: { language: LanguageCode; country: CountryCode; idx?: number }
158 | ): AppMetadata
=> {
159 | const res: Record = {};
160 |
161 | if (options.idx) res.position = options.idx + 1;
162 |
163 | for (const [property, getter] of Object.entries(appMetadataProperties).filter(([p]) =>
164 | properties.includes(p as P)
165 | )) {
166 | res[property] = getter(entry, options);
167 | }
168 |
169 | return res as AppMetadata;
170 | };
171 |
--------------------------------------------------------------------------------
/src/common/requests.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'cross-fetch';
2 | import { assert } from './assert';
3 |
4 | export type RequestPayload = [string, string];
5 | export type QueryParams = Record;
6 |
7 | const buildQueryString = (queryParams?: QueryParams) =>
8 | queryParams
9 | ? `?${Object.entries(queryParams)
10 | .map(([key, value]) => `${key}=${value}`)
11 | .join('&')}`
12 | : '';
13 |
14 | export const batchExecute = async (requests: RequestPayload[], queryParams?: QueryParams) => {
15 | const res = await fetch(`https://play.google.com/_/PlayStoreUi/data/batchexecute${buildQueryString(queryParams)}`, {
16 | method: 'POST',
17 | headers: {
18 | 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
19 | },
20 | // The (stringified) number at index 3 of each request is supposed to specify the order in which the responses
21 | // are returned (see: https://kovatch.medium.com/deciphering-google-batchexecute-74991e4e446c#ea4a). We want
22 | // them in the same order as the requests.
23 | //
24 | // From my testing, this doesn't always seem to be the case. But as the number is also returned in the response,
25 | // we can use it to manually sort the payloads later.
26 | body: `f.req=${encodeURIComponent(JSON.stringify([requests.map((r, idx) => [...r, null, '' + idx])]))}`,
27 | }).then((r) => r.text());
28 |
29 | const messages: any[] = JSON.parse(res.split('\n')[2]!);
30 | assert(() => messages.length === requests.length + 2, 'Has response payload for each request.');
31 |
32 | const payloads = messages.slice(0, requests.length).sort((a, b) => a[a.length - 1] - b[b.length - 1]);
33 |
34 | return payloads.map((payload, idx) => {
35 | assert(() => payload[0] === 'wrb.fr' && payload[1] === requests[idx]?.[0], 'Correct header.');
36 |
37 | const data = JSON.parse(payload[2]);
38 | assert(() => data, 'Has inner data.');
39 | return data;
40 | });
41 | };
42 |
--------------------------------------------------------------------------------
/src/endpoints/app-details.ts:
--------------------------------------------------------------------------------
1 | import { batchExecute, RequestPayload } from '../common/requests';
2 | import { assert } from '../common/assert';
3 | import { formatCurrency, type AppMetadata } from '../common/data-format';
4 | import type { DataTypeDeclaration } from './data-safety';
5 | import { dataSafetyLabelPurposes, type LanguageCode, type CountryCode } from '../common/consts';
6 |
7 | /** Parameters for a fetch app details request. */
8 | export type AppDetailsRequest = {
9 | /** The app ID. */
10 | appId: string;
11 | };
12 | /** Parameters for all fetch app details requests in a {@link fetchAppDetails} call. */
13 | export type AppDetailsOptions = {
14 | /** The country version of the Play Store to fetch from. */
15 | country: CountryCode;
16 | /** The language for descriptions, etc. */
17 | language: LanguageCode;
18 | };
19 |
20 | /** The properties present when fetching app details. */
21 | export const fetchAppDetailsMetadataProperties = [
22 | 'app_id',
23 | 'name',
24 | 'content_rating',
25 | 'released_on',
26 | 'downloads',
27 | 'downloads_exact',
28 | 'in_app_purchases',
29 | 'offered_by',
30 | 'rating',
31 | 'rating_counts',
32 | 'price',
33 | 'buy_url',
34 | 'top_chart_placement',
35 | 'developer',
36 | 'developer_path',
37 | 'developer_website_url',
38 | 'developer_email',
39 | 'developer_address',
40 | 'description',
41 | 'permissions',
42 | 'screenshot_urls',
43 | 'category',
44 | 'icon_url',
45 | 'cover_image_url',
46 | 'privacy_policy_url',
47 | 'trailer_url',
48 | 'tags',
49 | 'data_shared',
50 | 'data_collected',
51 | 'security_practices',
52 | 'version',
53 | 'requires_android',
54 | 'updated_on',
55 | ] as const;
56 | /** A property present when fetching app details. */
57 | export type AppMetadataPropertyFetchAppDetails = typeof fetchAppDetailsMetadataProperties[number];
58 | /** The result of a fetch app details request. */
59 | export type AppDetailsResult = AppMetadata;
60 |
61 | export const fetchAppDetailsRequestPayload = (request: AppDetailsRequest): RequestPayload => [
62 | 'Ws7gDc',
63 | JSON.stringify([
64 | null,
65 | null,
66 | [
67 | [
68 | 1, 9, 10, 11, 13, 14, 19, 20, 38, 43, 47, 49, 52, 58, 59, 63, 69, 70, 73, 74, 75, 78, 79, 80, 91, 92,
69 | 95, 96, 97, 100, 101, 103, 106, 112, 119, 129, 137, 138, 139, 141, 145, 146, 151, 155, 169,
70 | ],
71 | ],
72 | [
73 | [
74 | null,
75 | null,
76 | null,
77 | null,
78 | null,
79 | null,
80 | null,
81 | [null, 2],
82 | null,
83 | null,
84 | null,
85 | null,
86 | null,
87 | null,
88 | null,
89 | null,
90 | null,
91 | null,
92 | null,
93 | null,
94 | null,
95 | null,
96 | [1],
97 | ],
98 | [null, [[[]]], null, null, [1]],
99 | [null, [[[]]], null, [1]],
100 | [null, [[[]]]],
101 | null,
102 | null,
103 | null,
104 | null,
105 | [[[[]]]],
106 | [[[[]]]],
107 | ],
108 | null,
109 | [[request.appId, 7]],
110 | ]),
111 | ];
112 |
113 | const parseDslCategory = (c: any) => ({
114 | heading: c[1],
115 | description: c[2][1],
116 | icons: [c[0][3][2], c[0][10][2]],
117 | });
118 | const parseDslSection = (d: any, idx: number): DataTypeDeclaration[] | undefined =>
119 | !d
120 | ? undefined
121 | : !d[idx][0]
122 | ? []
123 | : (d[idx][0] as any[]).flatMap((r: any) =>
124 | (r[4] as any[]).map((d: any) => ({
125 | category: parseDslCategory(r[0]).heading,
126 | type: d[0],
127 | purposes: dataSafetyLabelPurposes.filter((p) => (d[2] as string).includes(p)),
128 | optional: d[1],
129 | }))
130 | );
131 | const parseSecurityPractices = (data: any) => {
132 | const securityPracticesEntries: any[] | undefined = data[137]?.[9]?.[2].map(parseDslCategory);
133 | assert(() => {
134 | if (!securityPracticesEntries) return true;
135 |
136 | const knownHeadings = new Set([
137 | 'Data is encrypted in transit',
138 | 'You can request that data be deleted',
139 | 'Data can’t be deleted',
140 | 'Data isn’t encrypted',
141 | 'Committed to follow the Play Families Policy',
142 | 'Independent security review',
143 | ]);
144 | return securityPracticesEntries?.map((e) => e.heading).every((h) => knownHeadings.has(h));
145 | }, 'Only known security practice entries.');
146 | const hasSecurityPracticeAttribute = (positiveHeading?: string, negativeHeading?: string) => {
147 | if (positiveHeading && securityPracticesEntries?.find((e) => e.heading === positiveHeading)) return true;
148 | if (negativeHeading && securityPracticesEntries?.find((e) => e.heading === negativeHeading)) return false;
149 | return undefined;
150 | };
151 |
152 | return securityPracticesEntries
153 | ? {
154 | data_encrypted_in_transit: hasSecurityPracticeAttribute(
155 | 'Data is encrypted in transit',
156 | 'Data isn’t encrypted'
157 | ),
158 | can_request_data_deletion: hasSecurityPracticeAttribute(
159 | 'You can request that data be deleted',
160 | 'Data can’t be deleted'
161 | ),
162 | committed_to_play_families_policy: hasSecurityPracticeAttribute(
163 | 'Committed to follow the Play Families Policy'
164 | ),
165 | independent_security_review: hasSecurityPracticeAttribute('Independent security review'),
166 | }
167 | : undefined;
168 | };
169 |
170 | export const parseAppDetailsPayload = (payload: any, options: AppDetailsOptions): AppDetailsResult | undefined => {
171 | if (!payload) return undefined;
172 | assert(
173 | () => payload.length === 3 && payload[0].flat(Infinity).length === 0 && payload[1].length === 40,
174 | 'Expected inner data structure.'
175 | );
176 | const data = payload[1][2];
177 |
178 | return {
179 | app_id: payload[1][11][0][0] || data[77][0],
180 | name: data[0][0],
181 | content_rating: data[9]
182 | ? { label: data[9][0], icon_url: data[9][1][3][2], interactive_elements: data[9][3]?.[1] }
183 | : undefined,
184 | released_on: data[10] ? new Date(data[10][1][0] * 1000) : undefined,
185 | downloads: data[13][0],
186 | downloads_exact: data[13][2],
187 | in_app_purchases: data[19]?.[0],
188 | offered_by: data[37][0],
189 | rating: data[51][0][1],
190 | rating_counts: {
191 | 1: data[51][1][1][1],
192 | 2: data[51][1][2][1],
193 | 3: data[51][1][3][1],
194 | 4: data[51][1][4][1],
195 | 5: data[51][1][5][1],
196 | total: data[51][2][1],
197 | },
198 | price: data[57][0][0][0][0]
199 | ? formatCurrency(data[57][0][0][0][0]?.[1][0][0] / 1e6, data[57][0][0][0][0]?.[1][0][1], options)
200 | : undefined,
201 | buy_url: data[57][0][0][0][0][6][5][2],
202 | top_chart_placement: data[58] ? { label: data[58][0], placement: data[58][2] } : undefined,
203 | developer: data[68][0],
204 | developer_path: data[68][1][4][2],
205 | developer_website_url: data[69][0]?.[5][2],
206 | developer_email: data[69][1][0],
207 | developer_address: data[69][2]?.[0],
208 | description: data[72][0][1],
209 | permissions: [
210 | ...(data[74][2][0] || []),
211 | ...(data[74][2][1] || []),
212 | [undefined, undefined, data[74][2][2], undefined],
213 | ]
214 | .filter((g) => g[2]?.length > 0)
215 | .map((g) => ({
216 | id: g[3]?.[0],
217 | name: g[0],
218 | icon_url: g[1]?.[3][2],
219 | permissions: g[2].map((p: any) => p[1]),
220 | })),
221 | screenshot_urls: data[78][0].map((s: any) => s[3][2]),
222 | category: data[79][0][0][0],
223 | icon_url: data[95][0][3][2],
224 | cover_image_url: data[96][0][3][2],
225 | privacy_policy_url: data[99]?.[0][5][2],
226 | trailer_url: data[100]?.[0][0][3][2],
227 | tags: data[118]
228 | ?.filter(Boolean)
229 | .map((g: any) => (typeof g[0][0][0] === 'string' ? g[0] : g[0][0]))
230 | .flat()
231 | .map((t: any) => ({ id: t[2], name: t[0], path: t[1][4][2] })),
232 | data_shared: parseDslSection(data[137][4], 0),
233 | data_collected: parseDslSection(data[137][4], 1),
234 | security_practices: parseSecurityPractices(data),
235 | version: data[140][0][0]?.[0],
236 | requires_android: data[140][1][1][0]
237 | ? { version: data[140][1][1][0][0][1], api_level: data[140][1][1][0][0][0] }
238 | : undefined,
239 | updated_on: new Date(data[145][0][1][0] * 1000),
240 | };
241 | };
242 |
243 | /**
244 | * Fetch the details/metadata of an app on the Google Play Store.
245 | *
246 | * This uses the Play Store's internal `batchexecute` endpoint with an RPC ID of `Ws7gDc`.
247 | *
248 | * @param request The parameters of which app to fetch the details of.
249 | * @param options Language and country options.
250 | * @returns The app details.
251 | */
252 | export async function fetchAppDetails(
253 | request: AppDetailsRequest | [AppDetailsRequest],
254 | options: AppDetailsOptions
255 | ): Promise;
256 | /**
257 | * Same as {@link fetchAppDetails} but for fetching the details of multiple apps at once. The details are all fetched in
258 | * a single API request.
259 | *
260 | * @see {@link fetchAppDetails}
261 | *
262 | * @param requests An array of fetch app details requests.
263 | * @param options The options for _all_ requests.
264 | * @returns An array of the app details, in the same order as the requests.
265 | */
266 | export async function fetchAppDetails(
267 | requests: AppDetailsRequest[],
268 | options: AppDetailsOptions
269 | ): Promise;
270 | export async function fetchAppDetails(requests: AppDetailsRequest | AppDetailsRequest[], options: AppDetailsOptions) {
271 | const _requests = Array.isArray(requests) ? requests : [requests];
272 | const data = await batchExecute(
273 | _requests.map((r) => fetchAppDetailsRequestPayload(r)),
274 | { hl: options.language, gl: options.country }
275 | );
276 | const res = data.map((d) => parseAppDetailsPayload(d, options));
277 | return _requests.length === 1 ? res[0] : res;
278 | }
279 |
--------------------------------------------------------------------------------
/src/endpoints/data-safety.ts:
--------------------------------------------------------------------------------
1 | import { batchExecute, RequestPayload } from '../common/requests';
2 | import {
3 | dataSafetyLabelPurposes,
4 | LanguageCode,
5 | DataSafetyLabelDataCategory,
6 | DataSafetyLabelDataType,
7 | DataSafetyLabelPurpose,
8 | } from '../common/consts';
9 | import { assert } from '../common/assert';
10 |
11 | /**
12 | * Parameters for a single data safety label request.
13 | *
14 | * @deprecated The separate function for fetching data safety labels is deprecated and will be removed in a future
15 | * release. Instead, you can use the {@link fetchAppDetails} function to fetch an app's metadata, which includes the
16 | * data safety label.
17 | */
18 | export type DataSafetyLabelRequest = {
19 | /**
20 | * The app's bundle ID.
21 | */
22 | app_id: string;
23 | };
24 | /**
25 | * Parameters for all data safety label requests in a {@link fetchDataSafetyLabels} call.
26 | */
27 | export type DataSafetyLabelsOptions = {
28 | /** The language for descriptions, etc. */
29 | language: LanguageCode;
30 | };
31 |
32 | /**
33 | * An app's declaration for a single data type in a data safety label.
34 | */
35 | export type DataTypeDeclaration = {
36 | /** The category the data type fits into. */
37 | category: DataSafetyLabelDataCategory;
38 | /** The data type. */
39 | type: DataSafetyLabelDataType;
40 | /** The purposes for which the data type is collected or shared. */
41 | purposes: DataSafetyLabelPurpose[];
42 | /** Whether the data type is marked as optional. */
43 | optional: boolean;
44 | };
45 | /**
46 | * An app's declared security practices in a data safety label.
47 | */
48 | export type DataSafetyLabelSecurityPracticesDeclarations = {
49 | /** Whether data collected or shared by the app uses encryption in transit. */
50 | data_encrypted_in_transit: boolean | undefined;
51 | /** Whether the app provides a way for users to request deletion of their data. */
52 | can_request_data_deletion: boolean | undefined;
53 | /**
54 | * Whether the developer has reviewed the app's compliance with Google Play's [Families policy
55 | * requirements](https://support.google.com/googleplay/android-developer/answer/9893335) (only for
56 | * applicable apps).
57 | */
58 | committed_to_play_families_policy: boolean | undefined;
59 | /** Whether the app has been independently validated against a global security standard. */
60 | independent_security_review: boolean | undefined;
61 | };
62 | /**
63 | * An app's data safety label.
64 | *
65 | * @deprecated The separate function for fetching data safety labels is deprecated and will be removed in a future
66 | * release. Instead, you can use the {@link fetchAppDetails} function to fetch an app's metadata, which includes the
67 | * data safety label.
68 | */
69 | export type DataSafetyLabel = {
70 | /** The app's name. */
71 | name: string;
72 | /** The app's bundle ID. */
73 | app_id: string;
74 | /** Data about the app's developer. */
75 | developer: {
76 | /** The developer's name */
77 | name: string;
78 | /** The relative path of the developer's page on the Play Store website. */
79 | path: string;
80 | /** The URL to the developer's website. */
81 | website_url: string | undefined;
82 | /** The developer's email address. */
83 | email: string;
84 | /** The developer's address. */
85 | address: string | undefined;
86 | };
87 | /** The URL to the app's icon. */
88 | icon_url: string;
89 | /** The URL to the app's privacy policy. */
90 | privacy_policy_url: string | undefined;
91 | /** An overview of the data that the app may share with other companies or organizations. */
92 | data_shared: DataTypeDeclaration[] | undefined;
93 | /** An overview of the data the app may collect. */
94 | data_collected: DataTypeDeclaration[] | undefined;
95 | /** An overview of the app's security practices. */
96 | security_practices: DataSafetyLabelSecurityPracticesDeclarations | undefined;
97 | };
98 |
99 | export const dataSafetyLabelsRequestPayload = (request: DataSafetyLabelRequest): RequestPayload => [
100 | 'Ws7gDc',
101 | // The numbers seem to determine which data points are returned.
102 | JSON.stringify([null, null, [[1, 69, 70, 96, 100, 138]], null, null, [[request.app_id, 7]]]),
103 | ];
104 |
105 | export const parseDataSafetyLabelPayload = (payload: any): DataSafetyLabel | undefined => {
106 | if (!payload) return undefined;
107 | assert(
108 | () => payload.length === 2 && payload[0].flat(Infinity).length === 0 && payload[1].length === 40,
109 | 'Expected inner data structure.'
110 | );
111 | const data = payload[1][2];
112 |
113 | const parseCategory = (c: any) => ({
114 | heading: c[1],
115 | description: c[2][1],
116 | icons: [c[0][3][2], c[0][10][2]],
117 | });
118 | const parseSection = (d: any, idx: number): DataTypeDeclaration[] | undefined =>
119 | !d
120 | ? undefined
121 | : !d[idx][0]
122 | ? []
123 | : (d[idx][0] as any[]).flatMap((r: any) =>
124 | (r[4] as any[]).map((d: any) => ({
125 | category: parseCategory(r[0]).heading,
126 | type: d[0],
127 | purposes: dataSafetyLabelPurposes.filter((p) => (d[2] as string).includes(p)),
128 | optional: d[1],
129 | }))
130 | );
131 |
132 | const securityPracticesEntries: any[] | undefined = data[137][9]?.[2].map(parseCategory);
133 | assert(() => {
134 | if (!securityPracticesEntries) return true;
135 |
136 | const knownHeadings = new Set([
137 | 'Data is encrypted in transit',
138 | 'You can request that data be deleted',
139 | 'Data can’t be deleted',
140 | 'Data isn’t encrypted',
141 | 'Committed to follow the Play Families Policy',
142 | 'Independent security review',
143 | ]);
144 | return securityPracticesEntries?.map((e) => e.heading).every((h) => knownHeadings.has(h));
145 | }, 'Only known security practice entries.');
146 | const hasSecurityPracticeAttribute = (positiveHeading?: string, negativeHeading?: string) => {
147 | if (positiveHeading && securityPracticesEntries?.find((e) => e.heading === positiveHeading)) return true;
148 | if (negativeHeading && securityPracticesEntries?.find((e) => e.heading === negativeHeading)) return false;
149 | return undefined;
150 | };
151 |
152 | return {
153 | name: data[0][0],
154 | app_id: payload[1][11][0][0],
155 | developer: {
156 | name: data[68][0],
157 | path: data[68][1][4][2],
158 | website_url: data[69][0]?.[5][2],
159 | email: data[69][1][0],
160 | address: data[69][2]?.[0],
161 | },
162 | icon_url: data[95][0][3][2],
163 | privacy_policy_url: data[99]?.[0][5][2],
164 | data_shared: parseSection(data[137][4], 0),
165 | data_collected: parseSection(data[137][4], 1),
166 | security_practices: securityPracticesEntries
167 | ? {
168 | data_encrypted_in_transit: hasSecurityPracticeAttribute(
169 | 'Data is encrypted in transit',
170 | 'Data isn’t encrypted'
171 | ),
172 | can_request_data_deletion: hasSecurityPracticeAttribute(
173 | 'You can request that data be deleted',
174 | 'Data can’t be deleted'
175 | ),
176 | committed_to_play_families_policy: hasSecurityPracticeAttribute(
177 | 'Committed to follow the Play Families Policy'
178 | ),
179 | independent_security_review: hasSecurityPracticeAttribute('Independent security review'),
180 | }
181 | : undefined,
182 | };
183 | };
184 |
185 | /**
186 | * Fetch and parse the given app's data safety label from the Google Play Store.
187 | *
188 | * This uses the Play Store's internal `batchexecute` endpoint with an RPC ID of `Ws7gDc`.
189 | *
190 | * @deprecated The separate function for fetching data safety labels is deprecated and will be removed in a future
191 | * release. Instead, you can use the {@link fetchAppDetails} function to fetch an app's metadata, which includes the
192 | * data safety label.
193 | *
194 | * @param request The parameters for which app to fetch.
195 | * @param options Language options.
196 | * @returns The data safety label.
197 | */
198 | export async function fetchDataSafetyLabels(
199 | request: DataSafetyLabelRequest | [DataSafetyLabelRequest],
200 | options: DataSafetyLabelsOptions
201 | ): Promise;
202 | /**
203 | * Same as {@link fetchDataSafetyLabels} but for fetching multiple data safety labels at once. The data safety labels
204 | * are fetched in a single API request.
205 | *
206 | * @deprecated The separate function for fetching data safety labels is deprecated and will be removed in a future
207 | * release. Instead, you can use the {@link fetchAppDetails} function to fetch an app's metadata, which includes the
208 | * data safety label.
209 | *
210 | * @see {@link fetchDataSafetyLabels}
211 | *
212 | * @param requests An array of data safety label requests.
213 | * @param options The options for _all_ requests.
214 | * @returns An array of the data safety labels, in the same order as the requests.
215 | */
216 | export async function fetchDataSafetyLabels(
217 | requests: DataSafetyLabelRequest[],
218 | options: DataSafetyLabelsOptions
219 | ): Promise<(DataSafetyLabel | undefined)[]>;
220 | export async function fetchDataSafetyLabels(
221 | requests: DataSafetyLabelRequest | DataSafetyLabelRequest[],
222 | options: DataSafetyLabelsOptions
223 | ) {
224 | const _requests = Array.isArray(requests) ? requests : [requests];
225 | const data = await batchExecute(
226 | _requests.map((r) => dataSafetyLabelsRequestPayload(r)),
227 | { hl: options.language }
228 | );
229 | const res = data.map(parseDataSafetyLabelPayload);
230 | return _requests.length === 1 ? res[0] : res;
231 | }
232 |
--------------------------------------------------------------------------------
/src/endpoints/search.ts:
--------------------------------------------------------------------------------
1 | import { batchExecute, RequestPayload } from '../common/requests';
2 | import { type LanguageCode, type CountryCode } from '../common/consts';
3 | import { assert } from '../common/assert';
4 | import { type AppMetadata, parseAppEntry, formatCurrency } from '../common/data-format';
5 |
6 | /**
7 | * Parameters for a search apps request.
8 | */
9 | export type SearchAppsRequest = {
10 | /** The term to search for. */
11 | searchTerm: string;
12 | };
13 | /**
14 | * Parameters for all search apps requests in a {@link searchApps} call.
15 | */
16 | export type SearchAppsOptions = {
17 | /** The country version of the Play Store to search in. */
18 | country: CountryCode;
19 | /** The language for descriptions, etc. */
20 | language: LanguageCode;
21 | };
22 |
23 | /** The properties present in the metadata of each app in the search results. */
24 | export const searchAppMetadataProperties = [
25 | 'position',
26 | 'app_id',
27 | 'icon_url',
28 | 'screenshot_urls',
29 | 'name',
30 | 'rating',
31 | 'category',
32 | 'price',
33 | 'buy_url',
34 | 'store_path',
35 | 'trailer_url',
36 | 'description',
37 | 'developer',
38 | 'downloads',
39 | 'cover_image_url',
40 | ] as const;
41 | /** A property present in the metadata of each app in the search results. */
42 | export type AppMetadataPropertySearch = typeof searchAppMetadataProperties[number];
43 | /**
44 | * A list of the search results.
45 | */
46 | export type SearchAppsResults = AppMetadata[];
47 |
48 | export const searchAppsRequestPayload = (request: SearchAppsRequest): RequestPayload => [
49 | 'lGYRle',
50 | JSON.stringify([
51 | [
52 | [],
53 | [
54 | // Number of results, but format is not understood.
55 | [8, [20, 50]],
56 | null,
57 | null,
58 | [
59 | 96, 108, 72, 100, 27, 183, 222, 8, 57, 169, 110, 11, 184, 16, 1, 139, 152, 194, 165, 68, 163, 211,
60 | 9, 71, 31, 195, 12, 64, 151, 150, 148, 113, 104, 55, 56, 145, 32, 34, 10, 122,
61 | ],
62 | null,
63 | null,
64 | null,
65 | null,
66 | ],
67 | // Search term.
68 | [request.searchTerm],
69 | // Type/clusters, 4 is Apps & Games (potentially with featured app).
70 | 4,
71 | null,
72 | null,
73 | null,
74 | [],
75 | null,
76 | ],
77 | [1],
78 | ]),
79 | ];
80 |
81 | export const parseSearchAppsPayload = (data: any, options: SearchAppsOptions): SearchAppsResults | undefined => {
82 | assert(() => data.length === 4 && data[0].length === 6, 'Expected outer data structure.');
83 |
84 | const sections = data[0][1];
85 | assert(() => sections.length === 2 || sections.length === 3, 'Has two or three sections.');
86 |
87 | const hasFeaturedApp = sections.length === 3;
88 |
89 | if (!hasFeaturedApp && !sections[1][22]) return [];
90 |
91 | const mainEntries: any[] = hasFeaturedApp ? sections[2][22][0] : sections[1][22][0];
92 | assert(() => Array.isArray(mainEntries), 'Has main results array.');
93 |
94 | const mainEntriesParsed = mainEntries.map((e, idx) => {
95 | assert(() => e.length === 1, 'Expected entry structure.');
96 | const meta = e[0];
97 |
98 | assert(() => meta.length === 102 || meta.length === 101, 'Meta length.');
99 |
100 | return parseAppEntry(meta, searchAppMetadataProperties, { ...options, idx: hasFeaturedApp ? idx + 1 : idx });
101 | });
102 |
103 | if (hasFeaturedApp) {
104 | const featuredEntry = sections[1][23];
105 |
106 | assert(() => featuredEntry.length === 18, 'Expected featured entry structure.');
107 | assert(
108 | () => featuredEntry[16].length === 40 && featuredEntry[16][2].length === 155,
109 | 'Featured entry inner meta length.'
110 | );
111 |
112 | const featuredEntryParsed = {
113 | position: 1,
114 | app_id: featuredEntry[16][11][0][0],
115 | icon_url: featuredEntry[16][2][95][0][3][2],
116 | screenshot_urls: featuredEntry[16][2][78][0].map((s: any) => s[3][2]),
117 | name: featuredEntry[16][2][0][0],
118 | rating: featuredEntry[16][2][51][0][1],
119 | category: featuredEntry[16][2][79][0][0][0],
120 | price: featuredEntry[16][2][57]
121 | ? formatCurrency(
122 | featuredEntry[16][2][57][0][0][0][0][1][0][0] / 1e6,
123 | featuredEntry[16][2][57][0][0][0][0][1][0][1],
124 | options
125 | )
126 | : undefined,
127 | buy_url: featuredEntry[16][2][57]?.[0][0][0][0][6][5][2],
128 | store_path: featuredEntry[17][0][0][4][2],
129 | trailer_url: featuredEntry[16][2][100]?.[0][0][3][2],
130 | description: featuredEntry[16][2][72][0][1],
131 | developer: featuredEntry[16][2][68][0],
132 | downloads: featuredEntry[16][2][13][0],
133 | cover_image_url: featuredEntry[16][2][96][0][3]?.[2],
134 | };
135 |
136 | return [featuredEntryParsed, ...mainEntriesParsed];
137 | }
138 |
139 | return mainEntriesParsed;
140 | };
141 |
142 | /**
143 | * Search for apps on the Google Play Stroe.
144 | *
145 | * This uses the Play Store's internal `batchexecute` endpoint with an RPC ID of `lGYRle`.
146 | *
147 | * @param request The parameters for what to search for.
148 | * @param options Language options.
149 | * @returns The search results.
150 | */
151 | export async function searchApps(
152 | request: SearchAppsRequest | [SearchAppsRequest],
153 | options: SearchAppsOptions
154 | ): Promise;
155 | /**
156 | * Same as {@link searchApps} but for doing multiple searches at once. The search results are fetched in a single API
157 | * request.
158 | *
159 | * @see {@link searchApps}
160 | *
161 | * @param requests An array of search apps requests.
162 | * @param options The options for _all_ requests.
163 | * @returns An array of the search results, in the same order as the requests.
164 | */
165 | export async function searchApps(
166 | requests: SearchAppsRequest[],
167 | options: SearchAppsOptions
168 | ): Promise<(SearchAppsResults | undefined)[]>;
169 | export async function searchApps(requests: SearchAppsRequest | SearchAppsRequest[], options: SearchAppsOptions) {
170 | const _requests = Array.isArray(requests) ? requests : [requests];
171 | const data = await batchExecute(
172 | _requests.map((r) => searchAppsRequestPayload(r)),
173 | { hl: options.language, gl: options.country }
174 | );
175 | const res = data.map((d) => parseSearchAppsPayload(d, options));
176 | return _requests.length === 1 ? res[0] : res;
177 | }
178 |
--------------------------------------------------------------------------------
/src/endpoints/top-charts.ts:
--------------------------------------------------------------------------------
1 | import { batchExecute, RequestPayload } from '../common/requests';
2 | import { CategoryId, LanguageCode, CountryCode } from '../common/consts';
3 | import { assert } from '../common/assert';
4 | import { AppMetadata, parseAppEntry } from '../common/data-format';
5 |
6 | /**
7 | * Parameters for a single top charts request.
8 | */
9 | export type TopChartsRequest = {
10 | /**
11 | * The chart to use, where `topselling_free`: Top free (or Top for €0, Top for $0, depending on the country);
12 | * `topgrossing`: Top grossing; `topselling_paid`: Top selling.
13 | */
14 | chart: 'topselling_free' | 'topgrossing' | 'topselling_paid';
15 | /** The category to use. Use `APPLICATION` for all apps, or `GAME` for all games, or one of the subcategory. */
16 | category: CategoryId;
17 | /** The number of apps to include in the top list. This seems to be limited to 660 apps. */
18 | count: number;
19 | };
20 | /**
21 | * Parameters for all top charts requests in a {@link fetchTopCharts} call.
22 | */
23 | export type TopChartsOptions = {
24 | /** The country for which to fetch the top chart(s). */
25 | country: CountryCode;
26 | /** The language for descriptions, etc. */
27 | language: LanguageCode;
28 | };
29 |
30 | /** The properties present in the metadata of each app in the top chart. */
31 | export const topChartsAppMetadataProperties = [
32 | 'position',
33 | 'app_id',
34 | 'icon_url',
35 | 'screenshot_urls',
36 | 'name',
37 | 'rating',
38 | 'category',
39 | 'price',
40 | 'buy_url',
41 | 'store_path',
42 | 'trailer_url',
43 | 'description',
44 | 'developer',
45 | 'downloads',
46 | 'cover_image_url',
47 | ] as const;
48 | /** A property present in the metadata of each app in the top chart. */
49 | export type AppMetadataPropertyTopCharts = typeof topChartsAppMetadataProperties[number];
50 |
51 | /**
52 | * A single app and its associated metadata on a top chart.
53 | */
54 | export type TopChartsEntry = AppMetadata;
55 | /**
56 | * A list of the entries on the respective top chart.
57 | */
58 | export type TopChartsResult = TopChartsEntry[];
59 |
60 | // This payload was determined by observing the network traffic on the web UI and then _drastically_ simplifying it
61 | // by throwing away everything that didn't affect the response.
62 | export const topChartsRequestPayload = (request: TopChartsRequest): RequestPayload => [
63 | 'vyAe2',
64 | JSON.stringify([[null, [[null, [null, request.count]], null, null, [113]], [2, request.chart, request.category]]]),
65 | ];
66 |
67 | /**
68 | * @deprecated This is now {@link parseAppEntry} instead. This alias will be removed in a future release.
69 | */
70 | export const parseTopChartEntry = parseAppEntry;
71 | export const parseTopChartPayload = (data: any, options: TopChartsOptions): TopChartsEntry[] | undefined => {
72 | assert(() => data.length === 1, 'One top-level array entry.');
73 | if (data[0][1] === null) return undefined;
74 |
75 | assert(
76 | () =>
77 | data[0][1][0].length === 29 &&
78 | data[0][1][0][3].length === 1 &&
79 | data[0][1][0].filter((i: unknown) => i === null).length === 27,
80 | 'Expected inner data structure.'
81 | );
82 |
83 | const entries: any[] = data[0][1][0][28][0];
84 | assert(() => entries.length > 0, 'Has data.');
85 |
86 | const parsed = entries.map((e, idx) => {
87 | assert(() => e.length === 3 && [0, 1, 2].includes(e[2]), 'Expected entry structure.');
88 |
89 | const meta = e[0];
90 |
91 | assert(() => meta.length === 23, 'Meta length.');
92 | assert(() => meta[8][8][0] === 'CAE=', 'Weird buy param.');
93 | assert(() => e[1].length === 1 && e[1][0].length === 3, 'Expected weird second meta object structure.');
94 | const empty_meta = e[1][0].flat(Infinity);
95 | assert(
96 | () =>
97 | empty_meta.filter((i: unknown) => i === null).length === empty_meta.length - 1 &&
98 | empty_meta[3] === meta[5],
99 | 'Weird second meta object only has category.'
100 | );
101 |
102 | return parseAppEntry(meta, topChartsAppMetadataProperties, { ...options, idx });
103 | });
104 |
105 | return parsed;
106 | };
107 |
108 | /**
109 | * Fetch and parse the current top chart rankings from the Play Store for the given criteria.
110 | *
111 | * This uses the Play Store's internal `batchexecute` endpoint with an RPC ID of `vyAe2`.
112 | *
113 | * @param request The parameters for which top chart to fetch.
114 | * @param options Language and country options.
115 | * @returns The top chart.
116 | */
117 | export async function fetchTopCharts(
118 | request: TopChartsRequest | [TopChartsRequest],
119 | options: TopChartsOptions
120 | ): Promise;
121 | /**
122 | * Same as {@link fetchTopCharts} but for fetching multiple top charts at once. The top charts are fetched in a single
123 | * API request.
124 | *
125 | * @see {@link fetchTopCharts}
126 | *
127 | * @param requests An array of top chart requests.
128 | * @param options The options for _all_ requests.
129 | * @returns An array of the top charts, in the same order as the requests.
130 | */
131 | export async function fetchTopCharts(
132 | requests: TopChartsRequest[],
133 | options: TopChartsOptions
134 | ): Promise<(TopChartsResult | undefined)[]>;
135 | export async function fetchTopCharts(requests: TopChartsRequest | TopChartsRequest[], options: TopChartsOptions) {
136 | const _requests = Array.isArray(requests) ? requests : [requests];
137 | const data = await batchExecute(
138 | _requests.map((r) => topChartsRequestPayload(r)),
139 | { hl: options.language, gl: options.country }
140 | );
141 | const res = data.map((d) => parseTopChartPayload(d, options));
142 | return _requests.length === 1 ? res[0] : res;
143 | }
144 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './endpoints/app-details';
2 | export * from './endpoints/data-safety';
3 | export * from './endpoints/search';
4 | export * from './endpoints/top-charts';
5 |
6 | export * from './common/consts';
7 |
8 | export { parseAppEntry } from './common/data-format';
9 | export type { AppMetadata, AppMetadataFull, AppMetadataProperty, PermissionGroup } from './common/data-format';
10 | export { batchExecute } from './common/requests';
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noEmit": true,
4 |
5 | "lib": ["ESNext"],
6 | "esModuleInterop": true,
7 | "resolveJsonModule": true,
8 |
9 | "strict": true,
10 | "strictNullChecks": true,
11 | "noUncheckedIndexedAccess": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "skipLibCheck": true
14 | },
15 | "include": ["src/**/*"]
16 | }
17 |
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://typedoc.org/schema.json",
3 |
4 | "plugin": ["typedoc-plugin-markdown"],
5 | "entryPoints": ["src/index.ts"],
6 | "out": "docs",
7 | "readme": "none",
8 | "excludeNotDocumented": true,
9 | "includeVersion": true,
10 | "githubPages": false,
11 | "gitRevision": "main"
12 | }
13 |
--------------------------------------------------------------------------------