MrAdex77/google-play-scraper

Google Play scraper for Node.js with a fully typed TypeScript API. App details, search, top charts, reviews, permissions and data safety. A modern replacement for the unmaintained google-play-scraper.

TypeScript

35

472 commits

updated Sep 24, 2026

See the code

See what people are saying

SourceMessageScoreDate

google-play-scraper 1.3.0 released! (r/SideProject)

Hey all, Just shipped 1.3.0 of my side project, a TypeScript library for pulling public Google Play data (app details, reviews, search, top charts and more). It started as a rewrite of an old package I kept patching for my own apps. Since the 1.0 launch in July it went from about 2.6k to 5.9k npm…

1

Sep 24, 2026

README

google-play-scraper

npm version npm downloads CI Live contract tests API docs coverage types node license

📖 API reference — browsable, always-current typed signatures for every method, option and error class, generated from the source on each release.

Scrape Google Play app data in Node.js with a fully typed TypeScript API. Fetch app details, search results, top charts, developer pages, similar apps, user reviews, permissions and data safety information directly from the Play Store. Built for ASO research, app market analysis, competitor tracking, review monitoring and data pipelines.

This is a modern TypeScript rewrite of the popular but unmaintained google-play-scraper package. The public method names, options and constants match the original, so migrating is usually just a matter of swapping the import.

Why this library

  • Fully typed results. Every method returns a precise TypeScript type derived from a zod schema that validates the scraped data at runtime.
  • No HTTP dependency. Runs on the native fetch of Node.js 22 and newer.
  • Resilient by design. Every fragile Google Play array path lives behind a spec layer with ordered fallback paths, so a single moved index does not take a whole call down.
  • Verified against live Google Play daily. Contract tests run against play.google.com on a daily schedule in CI and open a labeled issue the moment Google changes its layout.
  • Typed errors. Branch on NotFoundError, RateLimitError or SpecError instead of parsing message strings.
  • Throttling, retries and caching included. Rate limiting, exponential backoff with Retry-After support and an optional memoized client come standard.
  • ESM and CommonJS. Ships both module formats plus type declarations from a single package.

Comparison with the original google-play-scraper

Capability@mradex77/google-play-scraperfacundoolano/google-play-scraper
LanguageTypeScript in strict modeJavaScript
Type definitionsGenerated from zod schemasCommunity typings
Runtime validationzod/mini on every input and output boundaryNone
Error handlingTyped error classesPlain Error
Module formatsESM and CommonJS with .d.tsESM only
Runtime dependencieszod, lru-cachecheerio, got, memoizee, ramda, ...
Breakage detectionDaily live contract tests in CINone
MaintenanceActively maintainedUnmaintained

Table of contents

Installation

npm install @mradex77/google-play-scraper

Requires Node.js 22.12 or newer.

Every release is published from GitHub Actions with npm provenance, so the package on npm is cryptographically linked to the exact commit and workflow that built it. Verify your installed tree with:

npm audit signatures

CLI

Every method with a JSON-friendly surface is also available from the command line, no install required:

npx @mradex77/google-play-scraper app com.spotify.music
npx @mradex77/google-play-scraper search "sleep tracker" --num 5 --country de
npx @mradex77/google-play-scraper reviews com.mojang.minecraftpe --sort rating --num 20
npx @mradex77/google-play-scraper availability com.adex77.WhereAmI --countries us,pl,de
CommandPositionalOwn flags
app <appId>app id
apps <appIds>comma-separated app ids--concurrency
search <term>search term--num, --price, --full-detail
suggest <term>search term
list--collection, --category, --age, --num, --full-detail
developer <devId>developer id--num, --full-detail
similar <appId>app id--full-detail
reviews <appId>app id--num, --sort, --paginate, --token
permissions <appId>app id--short
data-safety <appId>app id
categories
availability <appId>app id--countries (comma-separated, required), --concurrency

Every command also accepts --lang <code>, --country <code> and --throttle <requestsPerSecond>, plus --help/-h (global or per command) and the global --version.

Results are pretty-printed JSON on stdout, so the output pipes straight into jq:

npx @mradex77/google-play-scraper app com.spotify.music | jq '{title, score, installs}'

Exit codes: 0 success, 1 scrape failure (not found, rate limited, blocked, network), 2 usage error (unknown command or flag, missing argument, invalid option value).

Quick start

The package exposes named exports and an aggregate default export. Use whichever style you prefer.

import gplay from '@mradex77/google-play-scraper';

const app = await gplay.app({ appId: 'com.google.android.apps.translate' });
console.log(app.title, app.score);
import { app, type App } from '@mradex77/google-play-scraper';

const details: App = await app({ appId: 'com.google.android.apps.translate' });
console.log(details.installs);

CommonJS works too:

const gplay = require('@mradex77/google-play-scraper').default;

gplay.search({ term: 'panda' }).then((results) => {
  console.log(results.length);
});

More runnable examples live in examples/.

Common options

Every method accepts a single options object. These options are available on all of them:

OptionTypeDefaultDescription
langstring'en'Two letter language code used to fetch the page.
countrystring'us'Two letter country code. Needed for apps available only in some regions.
throttlenumbernoneMaximum requests per second across a single call.
requestOptionsobjectnoneHTTP overrides. See Throttling and requestOptions.
onDegradationfunctionnoneCallback fired when a call degrades gracefully. See Monitoring drift.
onIntegrityEventfunctionnoneCallback fired when routing, parsing or pagination needs attention. See Monitoring drift.

Shared client

The top-level functions each build their own HTTP client, so a throttle passed to one call only limits the requests made inside that single call, such as the pagination pages of one search(), list(), developer(), or reviews(). Ten parallel app() calls with throttle: 1 each get their own limiter and still reach Google Play at the same time.

createClient fixes this by sharing one rate limiter, one set of request options, and one pair of lang/country defaults across every method of the client:

import { createClient } from '@mradex77/google-play-scraper';

const client = createClient({ country: 'pl', lang: 'pl', throttle: 5 });

const ids = ['com.foo', 'com.bar', 'com.baz'];
const details = await Promise.all(ids.map((appId) => client.app({ appId })));

That whole batch never exceeds five requests per second in total, across every method of the client, including the fullDetail app lookups made by search, list, developer, and similar.

Precedence rules:

  1. A client-level throttle creates one shared limiter that every method call goes through, including pagination pages and fullDetail lookups.
  2. A per-call throttle passed to a client method is superseded by the client limiter. When the client was created without throttle, a per-call value behaves exactly like the top-level functions do today (a call-scoped limiter).
  3. requestOptions merge shallowly, with per-call keys winning: { ...client, ...call }.
  4. Per-call lang and country win over the client defaults; the built-in en/us defaults apply last.
  5. A client-level onDegradation or onIntegrityEvent applies to every call, including the fullDetail and batch lookups the client makes on your behalf. A per-call callback replaces the client-level one for that call; callbacks do not chain.

Every method from the reference below is available on the client, alongside the exported constants. Two clients created with createClient are fully independent and never share a limiter. memoized accepts the same options and adds an LRU cache on top.

A per-call throttle on the top-level functions only rate-limits the requests within that single call, such as its pagination pages. Reach for createClient when you need one limit to span many calls.

Methods

  • app: full detail of a single application
  • search: apps matching a search term
  • suggest: search term autocompletions
  • list: a ranked collection of apps
  • developer: other apps by the same developer
  • similar: apps related to a given app
  • reviews: user reviews for an app
  • permissions: permissions an app requests
  • dataSafety: the data safety section of an app
  • categories: the Google Play category taxonomy
  • memoized: a shared client that caches equivalent calls

app

Retrieves the full detail of an application.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id (the ?id= url parameter).
import { app } from '@mradex77/google-play-scraper';

const details = await app({ appId: 'com.google.android.apps.translate' });

Returns an App with 55 fields. Trimmed:

{
  title: 'Google Translate',
  description: 'Translate between up to 133 languages...',
  descriptionHTML: 'Translate between up to 133 languages...<br>...',
  summary: 'Instantly translate text, speech and images in over 100 languages',
  installs: '1,000,000,000+',
  minInstalls: 1000000000,
  score: 4.48,
  scoreText: '4.5',
  ratings: 8765432,
  reviews: 5678901,
  histogram: { '1': 123456, '2': 45678, '3': 90123, '4': 234567, '5': 4567890 },
  price: 0,
  free: true,
  currency: 'USD',
  priceText: 'Free',
  available: true,
  offersIAP: false,
  androidVersion: '8.0',
  androidVersionText: '8.0 and up',
  developer: 'Google LLC',
  developerId: '5700313618786177705',
  developerEmail: 'apps-help@google.com',
  developerWebsite: 'http://support.google.com/translate',
  genre: 'Tools',
  genreId: 'TOOLS',
  categories: [{ name: 'Tools', id: 'TOOLS' }],
  icon: 'https://play-lh.googleusercontent.com/...',
  screenshots: ['https://play-lh.googleusercontent.com/...'],
  contentRating: 'Everyone',
  adSupported: false,
  updated: 1719878400000,
  version: 'Varies with device',
  comments: [],
  appId: 'com.google.android.apps.translate',
  url: 'https://play.google.com/store/apps/details?id=com.google.android.apps.translate'
}

A listing that has not been released yet carries no offer at all. For those apps preregister is true, and price, free and priceText fall back to 0, false and 'Free' rather than describing a real offer. Rating, install and release fields are absent too. Read preregister before trusting any price field:

const details = await app({ appId: 'com.ironhidegames.android.kingdomrush6.genesis' });

if (details.preregister) {
  console.log(`${details.title} has no price yet`);
}

The same applies to a search() row for an unreleased app: it is returned with price: 0 and free: false, so a price: 'free' filter will not match it.

Batch details

Fetches the full detail of many apps in one call, with a concurrency limit and per-id failure capture. One missing app never rejects the whole batch, and results stay aligned with the input order.

OptionTypeDefaultDescription
appIdsstring[]requiredBetween 1 and 250 Google Play ids.
concurrencynumber5Maximum number of app lookups in flight at once, from 1 to 20.
import { apps } from '@mradex77/google-play-scraper';

const result = await apps({
  appIds: ['com.whatsapp', 'com.spotify.music', 'com.gone.app'],
  concurrency: 5,
});

Each entry is a discriminated union keyed on status; narrow on it to read the parsed App or the typed error:

for (const entry of result) {
  if (entry.status === 'fulfilled') {
    console.log(entry.appId, entry.app.title);
  } else {
    console.error(entry.appId, entry.error.name);
  }
}
type AppsEntry =
  | { appId: string; status: 'fulfilled'; app: App }
  | { appId: string; status: 'rejected'; error: GooglePlayError };

apps does not deduplicate appIds within a call: a list with the same id twice fetches it twice. Use a memoized client when you want repeated ids, across or within batches, served from cache.

concurrency bounds parallelism; a client throttle bounds the request rate. When both apply, the effective rate is the smaller of the two constraints, so createClient({ throttle: 2 }).apps({ appIds, concurrency: 5 }) still issues at most two requests per second.

Country availability

Answers "in which countries is this app published?" by probing the Google Play details page once per country. Each probe is a plain GET whose HTTP status is the whole answer, so the body is never parsed and a probe is an order of magnitude cheaper than an app lookup. No other scraper offers this as a first-class call.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id to probe.
countriesstring[]requiredBetween 1 and 50 ISO 3166-1 alpha-2 codes, case insensitive.
langstring'en'The hl language of each probe.
concurrencynumber5Maximum number of probes in flight at once, from 1 to 20.
import { availability } from '@mradex77/google-play-scraper';

const result = await availability({
  appId: 'com.adex77.WhereAmI',
  countries: ['us', 'pl', 'de', 'jp'],
});
{
  appId: 'com.adex77.WhereAmI',
  countries: {
    us: { status: 'available' },
    pl: { status: 'available' },
    de: { status: 'available' },
    jp: { status: 'unavailable' },
  },
}

Each country resolves to one of three statuses:

  • available — the probe returned 200; the app is published in that storefront.
  • unavailable — the probe returned 404; the app is not published there.
  • error — the probe could not tell: a block (consent wall or captcha), a rate limit, any other HTTP failure, or a network error, with the original message preserved.

error is deliberately never collapsed into unavailable. A blocked or rate-limited probe means "we could not determine availability", which must not masquerade as "not published" in data used for release decisions. Country codes are returned lowercased, duplicates (ignoring case) are rejected, and a single call probes at most 50 countries.

Because Google Play geoblocking depends on the caller's IP as much as on the gl parameter, pair availability with createCountryFetch so each probe exits through a proxy located in the country it probes:

import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici';
import { availability, createCountryFetch } from '@mradex77/google-play-scraper';

const throughProxy = (proxyUrl: string): typeof fetch => {
  const dispatcher = new ProxyAgent(proxyUrl);
  return ((input: string | URL, init?: RequestInit) =>
    undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;
};

const fetchImpl = createCountryFetch({
  perCountry: {
    us: throughProxy('http://us-proxy.internal:8080'),
    de: throughProxy('http://de-proxy.internal:8080'),
  },
});

const result = await availability({
  appId: 'com.adex77.WhereAmI',
  countries: ['us', 'de'],
  requestOptions: { fetchImpl },
});

Each probe carries gl=<country>, so createCountryFetch routes the us probe through the US proxy and the de probe through the German proxy.

Retrieves apps that match a search term.

OptionTypeDefaultDescription
termstringrequiredThe search query.
numnumber20Number of results, up to 250. Best-effort above the Google cap, see below.
price'all' | 'free' | 'paid''all'Filter results by price.
fullDetailbooleanfalseWhen true, fetch and return the full App for each result.
import { search } from '@mradex77/google-play-scraper';

const results = await search({ term: 'panda', num: 5 });

Returns SearchResult[] (or App[] when fullDetail is true). Trimmed:

[
  {
    title: 'Panda VPN',
    appId: 'com.example.pandavpn',
    url: 'https://play.google.com/store/apps/details?id=com.example.pandavpn',
    icon: 'https://play-lh.googleusercontent.com/...',
    developer: 'Panda Labs',
    developerId: '1234567890',
    currency: 'USD',
    price: 0,
    free: true,
    summary: 'Fast and secure VPN',
    scoreText: '4.3',
    score: 4.3,
  },
];

Google Play currently serves only the first result page, roughly 30 apps and fewer for narrow terms, and provides no continuation token (verified July 2026). A num above that cap is best-effort: the returned array may be shorter than requested.

suggest

Given a partial term, returns up to five search completions.

OptionTypeDefaultDescription
termstringrequiredThe partial search query.
import { suggest } from '@mradex77/google-play-scraper';

const suggestions = await suggest({ term: 'pand' });

Returns string[]:

['panda', 'pandora', 'panda vpn', 'panda pop', 'pandora music'];

list

Retrieves a ranked collection of apps, optionally scoped to a category and an age bracket.

OptionTypeDefaultDescription
collectionCollectioncollection.TOP_FREEOne of TOP_FREE, TOP_PAID, GROSSING.
categoryCategorycategory.APPLICATIONAny category constant.
ageAgenoneOne of age.FIVE_UNDER, age.SIX_EIGHT, age.NINE_UP.
numnumber500Number of results.
fullDetailbooleanfalseWhen true, return the full App for each result.
import { list, collection, category } from '@mradex77/google-play-scraper';

const items = await list({
  collection: collection.TOP_FREE,
  category: category.GAME,
  num: 5,
});

Returns ListItem[] (or App[] when fullDetail is true), each shaped like a search result.

Google Play serves at most roughly 200 list items per request (verified July 2026), so a num above that ceiling, including the default 500, returns fewer items than requested.

developer

Returns other apps published by the same developer. The devId is either the numeric developer id or the developer name, exactly as it appears on Google Play.

OptionTypeDefaultDescription
devIdstringrequiredNumeric developer id or developer name.
numnumber60Number of results.
fullDetailbooleanfalseWhen true, return the full App for each result.
import { developer } from '@mradex77/google-play-scraper';

const apps = await developer({ devId: '5700313618786177705' });

Returns DeveloperApp[] (or App[] when fullDetail is true), each shaped like a search result.

similar

Returns apps related to a given app.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the reference app.
fullDetailbooleanfalseWhen true, return the full App for each result.
import { similar } from '@mradex77/google-play-scraper';

const apps = await similar({ appId: 'com.google.android.apps.translate' });

Returns SimilarApp[] (or App[] when fullDetail is true), each shaped like a search result.

reviews

Retrieves reviews for an app. Reviews always come back inside a { data, nextPaginationToken } envelope so paging is uniform.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the app.
sortSortsort.NEWESTOne of sort.NEWEST, sort.RATING, sort.HELPFULNESS.
numnumber150Number of reviews to fetch.
paginatebooleanfalseWhen true, fetch a single page and return its token.
nextPaginationTokenstringnoneContinue from a token returned by a previous call.
import { reviews, sort } from '@mradex77/google-play-scraper';

const first = await reviews({ appId: 'com.google.android.apps.translate', sort: sort.NEWEST });

if (first.nextPaginationToken) {
  const next = await reviews({
    appId: 'com.google.android.apps.translate',
    paginate: true,
    nextPaginationToken: first.nextPaginationToken,
  });
}

Returns ReviewsResult. Trimmed:

{
  data: [
    {
      id: 'gp:AOqpTOH...',
      userName: 'Ada Lovelace',
      userImage: 'https://play-lh.googleusercontent.com/...',
      date: '2026-06-30T12:00:00.000Z',
      score: 5,
      title: null,
      text: 'Works offline and the camera translation is great.',
      thumbsUp: 42,
      version: '8.9.0',
      criterias: []
    }
  ],
  nextPaginationToken: 'CqYBCqMB...'
}

permissions

Returns the permissions an app requests.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the app.
shortbooleanfalseWhen true, return a flat string[] of common permission names.
import { permissions } from '@mradex77/google-play-scraper';

const detailed = await permissions({ appId: 'com.google.android.apps.translate' });
const names = await permissions({ appId: 'com.google.android.apps.translate', short: true });

Returns AppPermission[] (or string[] when short is true):

[
  { permission: 'take pictures and videos', type: 0 },
  { permission: 'view network connections', type: 1 },
];

The type is permission.COMMON (0) or permission.OTHER (1).

dataSafety

Returns the data safety section of an app.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the app.
import { dataSafety } from '@mradex77/google-play-scraper';

const safety = await dataSafety({ appId: 'com.google.android.apps.translate' });

Returns DataSafety. Trimmed:

{
  sharedData: [
    { data: 'Approximate location', optional: false, purpose: 'App functionality', type: 'Location' }
  ],
  collectedData: [
    { data: 'Email address', optional: false, purpose: 'Account management', type: 'Personal info' }
  ],
  securityPractices: [
    { practice: 'Data is encrypted in transit', description: 'Your data is transferred over a secure connection' }
  ],
  privacyPolicyUrl: 'https://policies.google.com/privacy'
}

categories

Returns the Google Play category taxonomy as a list of category ids.

OptionTypeDefaultDescription
throttlenumbernoneSee common options.
requestOptionsobjectnoneSee common options.
import { categories } from '@mradex77/google-play-scraper';

const ids = await categories();

Returns string[]:

[
  'APPLICATION',
  'ANDROID_WEAR',
  'ART_AND_DESIGN',
  'AUTO_AND_VEHICLES',
  'GAME',
  'GAME_ACTION',
  'FAMILY',
];

memoized

Returns a shared client whose promise-returning methods share an LRU cache held in memory, so equivalent calls made within the TTL resolve from cache instead of hitting Google Play again. It accepts every createClient option plus the two cache bounds:

OptionTypeDefaultDescription
langstring'en'Language default applied to every call that does not set its own.
countrystring'us'Country default applied to every call that does not set its own.
throttlenumbernoneOne shared limiter for every request the client makes, cache misses included.
requestOptionsobjectnoneClient-level HTTP overrides, merged under per-call ones. See requestOptions.
onDegradationfunctionnoneClient-level degradation callback. See Monitoring drift.
onIntegrityEventfunctionnoneClient-level integrity callback. See Monitoring drift.
maxAgeMsnumber300000Time to live per cache entry, in milliseconds, counted from the moment the filling request completes. A positive integer up to 2147483647.
maxnumber1000Maximum number of cached entries. A positive integer up to 1000000.
import { memoized } from '@mradex77/google-play-scraper';

const client = memoized({ country: 'pl', lang: 'pl', throttle: 5, maxAgeMs: 60000, max: 500 });

await client.app({ appId: 'com.google.android.apps.translate' });
await client.app({ appId: 'com.google.android.apps.translate' });

Invalid options throw ValidationError before any cache or HTTP state exists.

What counts as the same call. Entries are keyed by the options after validation and defaulting, so these all share one entry: {} and { lang: 'en', country: 'us' }, country: 'US' and country: 'us', and the same options in any property order. Anything that changes the response or how it is obtained separates entries: lang, country, every feature option, and the headers, timeoutMs, retries, fetchImpl and signal fields of requestOptions (the last two by object identity, so two callers sharing a signal share an entry and one caller's abort never rejects an unrelated caller; a fresh signal on every call therefore never hits, so prefer timeoutMs for per-call deadlines). Pacing and telemetry never fragment the cache: throttle, concurrency, onDegradation, onIntegrityEvent, onRequest, onResponse and onRetry are ignored by the key, so inline callbacks are fine.

What is cached. app, availability, search, suggest, list, categories, developer, similar, reviews, permissions and dataSafety. Concurrent equivalent calls share one in-flight request. A hit resolves to the same object the filling call returned, so treat cached results as read-only. A rejected call is never cached, so the next call retries. apps and the fullDetail lookups of search, list, developer and similar are served from the app entries they warm. reviewsIterator, searchIterator, developerIterator, reviewsAll and apps are not cached themselves; they share the client's defaults, limiter and request options and pass through to Google Play.

Cache hits replay their events. Degradation and integrity events recorded while an entry was fetched are delivered again, in their original order, to the callbacks of every later call that hits the entry. A hit performs no request, so onRequest, onResponse and onRetry are not replayed. A replayed callback that throws rejects only the call it was passed to; the entry stays valid for other callers. Events recorded before a call fails are still delivered to every caller that shared the failed request. Events raised by the app lookups a fullDetail call or an apps batch makes on your behalf belong to that outer call: they reach its callbacks, and a cached fullDetail result replays them with its entry.

Cache controls. The client exposes explicit control without global state:

client.cache.size;
client.cache.invalidate('app', { appId: 'com.google.android.apps.translate' });
client.cache.clear();

invalidate takes the method name and the same options you would pass to that method, applies the client defaults and validation to them (throwing ValidationError for options the method itself would reject), and drops the matching entry, returning whether one existed. The next equivalent call requests again while unrelated entries survive. size counts live entries and falls as entries expire or are evicted.

Streaming and bulk reads

reviews(), search(), and developer() collect a fixed num of items before they resolve. When you want to walk results lazily and stop exactly when you have seen enough, use the async iterators. They fetch nothing until the first for await, and they stop fetching the moment the consumer breaks, so "first ten reviews then stop" costs exactly one page request.

The iterators are available both as standalone functions and as methods on createClient, where they share the client's limiter and defaults.

reviewsIterator

Streams reviews one at a time across pages.

import { reviewsIterator } from '@mradex77/google-play-scraper';

for await (const review of reviewsIterator({ appId: 'com.whatsapp' })) {
  if (review.score < 3) {
    break;
  }
  console.log(review.userName, review.score);
}

Pass nextPaginationToken to resume a stream where a previous run left off. The token comes from a prior reviews({ paginate: true }) call or from persisted state:

const page = await reviews({ appId: 'com.whatsapp', paginate: true });

const resumed = reviewsIterator({
  appId: 'com.whatsapp',
  nextPaginationToken: page.nextPaginationToken ?? undefined,
});

reviewsAll

Drains reviewsIterator into an array. Popular apps hold millions of reviews, so pass maxReviews to cap the read; the generator never fetches a page beyond the one that contains the last item you asked for. Combine it with a client throttle to stay within Google Play's rate limits.

import { createClient } from '@mradex77/google-play-scraper';

const client = createClient({ throttle: 5 });
const recent = await client.reviewsAll({ appId: 'com.whatsapp', maxReviews: 2000 });

Without maxReviews, reviewsAll walks the entire review history, which can be very large.

searchIterator and developerIterator

import { searchIterator, developerIterator } from '@mradex77/google-play-scraper';

for await (const result of searchIterator({ term: 'geography quiz' })) {
  console.log(result.appId);
}

for await (const item of developerIterator({ devId: 'Google LLC' })) {
  console.log(item.title);
}

Non-goals

  • No listIterator. list() sends a single batchexecute RPC that returns up to num items in one response; there is no server-side pagination token to iterate. Faking an iterator over one response would misrepresent the network behavior.
  • Iterators do not accept fullDetail or num. Streaming consumers call client.app() per item when they need full details, and the consumer controls the end by breaking; baking N+1 lookups or a fixed count into a generator hides its real cost. Use reviewsAll({ maxReviews }) for the bounded case.

Constants

The library exports the same constant sets as the original, frozen and typed.

ConstantValues
categoryAll app and game categories plus the FAMILY set (e.g. APPLICATION, TOOLS, GAME, GAME_PUZZLE, FAMILY).
collectionTOP_FREE, TOP_PAID, GROSSING.
sortNEWEST (2), RATING (3), HELPFULNESS (1).
ageFIVE_UNDER ('AGE_RANGE1'), SIX_EIGHT ('AGE_RANGE2'), NINE_UP ('AGE_RANGE3').
permissionCOMMON (0), OTHER (1).
import { category, collection, sort, age, permission } from '@mradex77/google-play-scraper';

Error handling

Every failure surfaces as a typed subclass of GooglePlayError, so you can branch on the exact cause instead of parsing message strings.

ErrorExtendsThrown when
GooglePlayErrorErrorBase class for every error the library throws.
ValidationErrorGooglePlayErrorThe options you passed fail their zod schema.
HttpErrorGooglePlayErrorA request fails with an unsuccessful status or a network error. Carries status and url.
NotFoundErrorHttpErrorGoogle Play responds 404, e.g. an unknown appId.
RateLimitErrorHttpErrorGoogle Play responds 429 after retries are exhausted.
BlockedErrorGooglePlayErrorA consent wall redirect, or a captcha challenge redirect that retries cannot clear.
ParseErrorGooglePlayErrorA batchexecute response cannot be parsed.
SpecErrorParseErrorExtraction fails; lists every failing field and the paths that were tried.
import { app, NotFoundError, SpecError } from '@mradex77/google-play-scraper';

try {
  const details = await app({ appId: 'com.does.not.exist' });
} catch (error) {
  if (error instanceof NotFoundError) {
    console.error('No such app');
  } else if (error instanceof SpecError) {
    console.error('Google Play changed its layout:', error.failures);
  } else {
    throw error;
  }
}

Failure behavior

An unknown appId or devId does not fail the same way everywhere, because Google Play itself does not: HTML details surfaces respond 404, while report surfaces serve an empty payload. Both behaviors are pinned by the live test suite.

Behavior for a missing appMethods
Throws NotFoundErrorapp, apps (per entry, as a rejected PromiseSettledResult), similar, developer
Resolves with a typed empty valuesearch and suggest ([]), reviews ({ data: [], nextPaginationToken: null }), permissions ([]), dataSafety (empty arrays, no privacyPolicyUrl)
Maps the throw to a statusavailability reports unavailable

Typed empties are limited to the documented response shapes above and other schema-backed empty variants. A structurally missing or malformed response root throws ParseError; a missing required field inside a valid root throws SpecError. Neither case is converted into an empty result, 0, false, Free, or VARY unless that exact fallback is part of the documented field contract.

Throttling and requestOptions

Pass throttle to cap requests per second, and requestOptions to override the HTTP layer:

requestOptions fieldTypeDescription
headersRecord<string, string>Extra headers merged into every request.
fetchImpltypeof fetchA custom fetch implementation, useful for proxies and tests. Combine with createCountryFetch to route each storefront country separately.
timeoutMsnumberTimeout per request, up to 120000. Default 30000.
retriesnumberRetry count for 429, 5xx and captcha challenge redirects, 0 to 5. Default 2.
signalAbortSignalCancels the call at once, including an in-flight request, a body read, retry backoff, a Retry-After wait and a queued throttle slot. See Cancellation.
onRequest(event: RequestEvent) => voidCalled before every attempt, including retries. See Request lifecycle hooks.
onResponse(event: ResponseEvent) => voidCalled for each settled response — after the body is read on success — with status and durationMs.
onRetry(event: RetryEvent) => voidCalled when a retry is scheduled, with delayMs and reason.
import { app } from '@mradex77/google-play-scraper';

const details = await app({
  appId: 'com.google.android.apps.translate',
  throttle: 5,
  requestOptions: {
    timeoutMs: 15000,
    retries: 3,
    headers: { 'Accept-Language': 'de' },
    fetchImpl: myProxiedFetch,
  },
});

Retries use exponential backoff with jitter. A Retry-After response header overrides the backoff when it is well formed, in either form RFC 9110 allows: a count of seconds, or an HTTP date, which is resolved against the response Date header when the server sends one and against the local clock otherwise.

A Retry-After longer than 60 seconds is not waited on. The call ends immediately with the mapped status error, RateLimitError for a 429 or BlockedError for a captcha challenge redirect, so a server cannot park a request for minutes. The ceiling is fixed and independent of timeoutMs, which bounds a single attempt. A malformed, past or negative value is ignored and normal jittered backoff applies.

Request lifecycle hooks

The three on* callbacks make request telemetry observable at every level — direct calls, createClient, and memoized — including what wrapping fetchImpl cannot see: retries and backoff decisions.

import { app } from '@mradex77/google-play-scraper';

const details = await app({
  appId: 'com.google.android.apps.translate',
  requestOptions: {
    onRequest: (event) => log.debug('gplay request', event),
    onResponse: (event) => metrics.timing('gplay.latency', event.durationMs),
    onRetry: (event) => metrics.increment('gplay.retry', { reason: event.reason }),
  },
});

Every event carries url, method, and a 1-based attempt: the first try is attempt 1, a RetryEvent carries the number of the attempt that just failed, and the subsequent RequestEvent is that attempt plus one. ResponseEvent adds status and durationMs, measured from just before the fetch (throttle wait excluded) and, on success, through the transfer of the full body. RetryEvent adds the scheduled delayMs, a reason of 'status' or 'network', and the HTTP status when the reason is a retryable status. A retry-free happy path emits exactly one RequestEvent and one ResponseEvent.

Hooks are telemetry, never control flow. A hook that throws or returns a rejecting promise is swallowed and the request proceeds unchanged — the deliberate opposite of onDegradation and onIntegrityEvent, which rethrow because they are data-integrity signals. When a client-level and a call-level requestOptions set the same hook, the call-level one replaces it for that call; hooks do not chain.

Routing requests through a proxy

The native fetch of Node.js ignores the HTTP_PROXY and HTTPS_PROXY environment variables by default. On Node.js 24 and newer, opt in when launching your process:

NODE_USE_ENV_PROXY=1 node app.js

For per-call control, or on Node.js 22, inject a proxied fetch from undici through fetchImpl:

import { ProxyAgent, fetch as proxiedFetch, type RequestInit } from 'undici';
import { app } from '@mradex77/google-play-scraper';

const dispatcher = new ProxyAgent('http://user:password@proxy.example.com:8080');

const fetchImpl = ((input: string | URL, init?: RequestInit) =>
  proxiedFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;

const details = await app({
  appId: 'com.google.android.apps.translate',
  requestOptions: { fetchImpl },
});

ProxyAgent also accepts an options object when the proxy needs more configuration, such as a token carrying a preformatted Proxy-Authorization header or TLS settings for the proxy connection.

Rotating proxies when blocked

Google answers a burst of requests from one IP address with a redirect to its captcha challenge at www.google.com/sorry. The client retries that redirect like a 429, and most challenges clear on the next attempt. When they keep coming until retries run out, or the challenge asks to wait longer than the Retry-After ceiling, the call rejects with a BlockedError whose message names the cause (captcha challenge or consent wall), which is the signal to slow down or switch to another exit IP:

import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici';
import { BlockedError, createClient } from '@mradex77/google-play-scraper';

const throughProxy = (proxyUrl: string): typeof fetch => {
  const dispatcher = new ProxyAgent(proxyUrl);
  return ((input: string | URL, init?: RequestInit) =>
    undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;
};

const routes = [
  'http://user:password@proxy-1.example.com:8080',
  'http://user:password@proxy-2.example.com:8080',
].map((proxyUrl) =>
  createClient({ throttle: 2, requestOptions: { fetchImpl: throughProxy(proxyUrl) } }),
);

async function searchWithRotation(term: string) {
  for (const client of routes) {
    try {
      return await client.search({ term });
    } catch (error) {
      if (!(error instanceof BlockedError)) {
        throw error;
      }
    }
  }
  throw new Error('Every proxy is blocked');
}

The challenge is driven by request rate per IP, so each route gets its own createClient whose limiter spans every call through that proxy, concurrent ones included, and prevents most blocks before rotation is needed. A per-call throttle on search would only pace the requests inside that one call. Detection reads only the final response URL, never page text, so a search term or review that quotes a captcha message is returned as data.

Routing by country

createCountryFetch builds a fetch implementation that picks a route per storefront country, so requests for each country option go through their own proxy. It reads the gl query parameter that Google Play requests carry, matches it against perCountry (ISO 3166-1 alpha-2, case insensitive), and falls back to fallback or the global fetch when no route matches:

import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici';
import { app, createCountryFetch } from '@mradex77/google-play-scraper';

const throughProxy = (proxyUrl: string): typeof fetch => {
  const dispatcher = new ProxyAgent(proxyUrl);
  return ((input: string | URL, init?: RequestInit) =>
    undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;
};

const fetchImpl = createCountryFetch({
  perCountry: {
    us: throughProxy('http://us-proxy.internal:8080'),
    de: throughProxy('http://user:password@de-proxy.internal:8080'),
  },
  fallback: throughProxy('http://default-proxy.internal:8080'),
});

const details = await app({
  appId: 'com.google.android.apps.translate',
  country: 'de',
  requestOptions: { fetchImpl },
});

Routes accept any typeof fetch, so the same helper also works for per-country rate limiting, logging or fixtures in tests. Omit fallback to send unmatched countries through a direct connection. The only request without a gl parameter is the dataSafety page fetch, which always uses the fallback route.

Cancellation

Pass an AbortSignal to cancel long running calls, such as a reviews fetch that walks many pages. An aborted call rejects with the signal's reason and is never retried:

import { reviews } from '@mradex77/google-play-scraper';

const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);

const result = await reviews({
  appId: 'com.google.android.apps.translate',
  num: 5000,
  requestOptions: { signal: controller.signal },
});

Cancellation is immediate at every point in the request lifecycle, not only while a request is on the wire:

Where the abort landsWhat happens
Before the call startsRejects without contacting Google Play; no onRequest event is emitted
During the request or the body readRejects with the signal's reason, not a wrapped HttpError
During retry backoff or a Retry-After waitRejects before the delay expires; no further attempt is made
While queued behind a throttle slotRejects at once; the slot is released and the other queued calls keep the configured rate

The reason you pass to abort() is the value that reaches you, by identity, so a sentinel object survives the round trip:

import { search } from '@mradex77/google-play-scraper';

const budgetExceeded = { reason: 'budget-exceeded' };
const controller = new AbortController();
setTimeout(() => controller.abort(budgetExceeded), 5000);

try {
  await search({ term: 'panda', num: 250, requestOptions: { signal: controller.signal } });
} catch (error) {
  if (error !== budgetExceeded) {
    throw error;
  }
}

Calling abort() with no argument produces the standard AbortError DOMException. A per-request timeout is separate: timeoutMs ends one attempt and may still be retried, while your own abort is terminal.

A custom fetchImpl should honor init.signal so an aborted request is cut short on the wire. One that ignores it cannot be interrupted mid-request, but the call still rejects with your reason as soon as that request settles, and its body is discarded.

Resilience

Google Play serves its data as deeply nested, unlabeled arrays whose positions shift a few times a year. That is what breaks scrapers. Every positional path in this library lives as a typed constant in a specs.ts file per feature, never inline in logic, and each field is resolved through an ordered list of candidate paths so a single moved index does not take the whole call down. Extraction collects all field failures in one pass and throws a single SpecError naming every broken field and the paths that were tried, which is exactly the input the maintenance runbook needs. Unknown data enters as unknown and only leaves through a zod schema, so a layout change fails loudly at the boundary rather than three layers up.

To catch breakage before users do, the e2e/ suite runs against live Google Play on a daily GitHub Actions schedule and opens a labeled issue on failure. Repairing a break is a spec diff confined to one file, walked through step by step in docs/RUNBOOK.md.

Monitoring drift

Cluster pagination degrades gracefully: when a continuation page from Google stops parsing, the call keeps everything collected so far and returns instead of throwing. The onDegradation callback makes every swallowed continuation failure observable. The separate onIntegrityEvent callback reports routing fallbacks, skipped best-effort sections, and pagination token cycles without widening the existing DegradationEvent contract.

The option exists on every method for uniformity, but only the methods that paginate through the cluster endpoint can emit: search, searchIterator, similar, developer, and developerIterator. Each event carries the feature context, the machine readable reason ('cluster-page-parse'), and the underlying ParseError:

import { search, type DegradationEvent } from '@mradex77/google-play-scraper';

const onDegradation = (event: DegradationEvent): void => {
  metrics.increment('gplay.degradation', {
    context: event.context,
    reason: event.reason,
  });
};

const results = await search({ term: 'panda', num: 100, onDegradation });

A degraded call still resolves with the pages that parsed, so wire the callback to a metrics counter or log sink rather than treating it as an error path. If the callback itself throws, the error surfaces to the caller unchanged.

Integrity events use the same shape but a separate type and callback:

import { app, type IntegrityEvent } from '@mradex77/google-play-scraper';

const onIntegrityEvent = (event: IntegrityEvent): void => {
  metrics.increment('gplay.integrity', {
    context: event.context,
    reason: event.reason,
  });
};

const details = await app({
  appId: 'com.google.android.apps.translate',
  onIntegrityEvent,
});
CallbackReasonMeaning
onDegradationcluster-page-parseA cluster continuation failed to parse and collected results returned.
onIntegrityEventrpc-anchor-fallbackAn RPC anchor used its validated absolute fallback.
onIntegrityEventoptional-section-parseA present best-effort section failed to parse and was skipped.
onIntegrityEventpagination-token-cycleA repeated token stopped pagination before a duplicate request.
onIntegrityEventsection-anchor-fallbackA best-effort section resolved outside its declared anchor.

Two boundaries to know:

  • An empty continuation page emits nothing: that is the normal end-of-results signal and is indistinguishable from exhaustion.
  • app emits optional-section-parse with context app comments when the available comment roots are structurally invalid. A valid empty comment root returns an empty list without an event. Treat a rising event rate as a signal, not each event.
  • reviews pagination never swallows a parse failure. Malformed review pages reject with ParseError, while a repeated token stops safely and emits pagination-token-cycle.

With memoized(), callbacks never affect which entry a call hits. Degradation and integrity events are recorded with the entry and replayed, in order, to the callbacks of every call that hits it, so a cached degraded result is reported as degraded to each caller. Lifecycle hooks are not replayed, because a hit performs no request. See memoized.

Versioning and API stability

This package follows Semantic Versioning. For a scraper the contract needs one clarification: semver covers the code surface this library controls, not the data Google serves.

Integrity diagnostics use the additive API choice (Option B): the existing DegradationEvent.reason remains exactly 'cluster-page-parse', and the four new reasons live on the opt-in onIntegrityEvent callback. This keeps exhaustive switches over DegradationEvent.reason and narrowly typed onDegradation handlers intact while adding observability without a major release. IntegrityReason itself is a widening union, so a new reason is a minor release and an exhaustive switch over it needs a new arm.

ChangeRelease
Removing or renaming an exported function, option, or constantmajor
Removing a field from a result schema, or changing its typemajor
Raising the Node.js support floor or dropping a module formatmajor
Adding a new method, option, or optional result fieldminor
Adding a reason to IntegrityReasonminor
Restoring extraction of a field after a Google Play layout changepatch

What semver cannot cover is the content behind those shapes. Google Play changes its markup a few times a year, and a field can start coming back undefined, empty, or degraded without any release of this package. The policy for that case:

  • The daily live test suite fails and an issue is filed automatically, usually before user reports arrive.
  • When extraction can be restored, the fix ships as a patch.
  • When Google removes the underlying data permanently, the field stays in the schema as a documented degraded surface first (see categories for the worked example) and is only removed in the next major.

To watch for drift in your own production use, wire up onDegradation and treat SpecError as a layout-change signal rather than an application bug.

Migrating from google-play-scraper

The method names, options, and constants are the same, so most code keeps working after swapping the import. Watch for these differences:

  • reviews always returns the { data, nextPaginationToken } envelope, never a bare array.
  • Dates are ISO 8601 strings (review date, replyDate), and updated is a millisecond timestamp.
  • Errors are the typed classes above instead of plain Error.
  • The package is ESM first with a CommonJS build; the default export is the aggregate client and named exports are also available.

Deriving from the exported schemas

Every exported schema is a zod/mini schema. .parse and .safeParse behave exactly like their classic zod counterparts, but transform methods such as .extend, .pick, and .omit live as top-level functions on the zod/mini entry of the same zod package:

import * as z from 'zod/mini';
import { appSchema } from '@mradex77/google-play-scraper';

const slimAppSchema = z.pick(appSchema, { title: true, appId: true, score: true });

The zod/mini surface is what keeps the library small in your bundle. Measured with esbuild --bundle --minify --format=esm --platform=node against zod 4.4.3:

BundleMinifiedGzip
Classic zod (v0.4)414.8 kB85.8 kB
zod/mini (current)115.1 kB29.9 kB

Install size on disk is unchanged by design: zod/mini is a subpath export of the same zod package, so node_modules stays identical while the consumer bundle shrinks about 3.6×.

FAQ

Is there an official Google Play API for app data?

No. Google does not offer a public API for store listings, search results or reviews. Libraries like this one fill that gap by scraping the public web pages and internal endpoints that power play.google.com. This is the standard approach for ASO tools, market research and academic studies.

How do I scrape Google Play reviews for an app?

Call reviews with the app id. Use paginate: true and the returned nextPaginationToken to walk through all pages. Combine it with throttle to stay under the rate limits.

How do I avoid getting rate limited or blocked?

Set the throttle option to cap requests per second, keep the default retry behavior, and reuse results through the memoized client. If you run large jobs, spread them out over time or route them through a proxy. A RateLimitError or BlockedError tells you exactly when Google started pushing back, and rotating proxies shows how to react to a block.

Does this library work in the browser?

No. It targets Node.js 22.12 and newer. Browsers cannot scrape Google Play anyway because of CORS restrictions.

Is this library affiliated with Google?

No. It is an independent open source project. All app data belongs to its respective owners, and you are responsible for using it in compliance with the Google Play Terms of Service and applicable law.

Contributing

Contributions are welcome. See CONTRIBUTING.md for the development setup, commit conventions and test workflow. Found a field that stopped resolving? Open an issue with the SpecError output, or follow docs/RUNBOOK.md to fix the spec yourself.

pnpm install
pnpm lint            eslint on the whole repo
pnpm typecheck       tsc --noEmit
pnpm test            unit tests, offline against recorded fixtures
pnpm test:coverage   unit tests with coverage thresholds
pnpm test:e2e        live contract tests against play.google.com
pnpm build           emit dist/ with esm, cjs, and d.ts
pnpm check:package   build then verify the published package

Disclaimer

This project is not affiliated with, endorsed by or sponsored by Google. It accesses publicly available data only. Use it responsibly, respect the Google Play Terms of Service, and throttle your requests.

License

MIT

android
api
app-data
app-reviews
app-store-optimization
aso
crawler
googe-play-api
google-play
google-play-scraper
market-research
nodejs
play-store
playstore
scraper
typescript
web-scraper

Contributors

MrAdex77

447 commits

dependabot[bot]

10 commits

s-h-a-d-o-w

5 commits

MrAdex77/google-play-scraper

Google Play scraper for Node.js with a fully typed TypeScript API. App details, search, top charts, reviews, permissions and data safety. A modern replacement for the unmaintained google-play-scraper.

TypeScript

35

472 commits

updated Sep 24, 2026

See the code

See what people are saying

SourceMessageScoreDate

google-play-scraper 1.3.0 released! (r/SideProject)

Hey all, Just shipped 1.3.0 of my side project, a TypeScript library for pulling public Google Play data (app details, reviews, search, top charts and more). It started as a rewrite of an old package I kept patching for my own apps. Since the 1.0 launch in July it went from about 2.6k to 5.9k npm…

1

Sep 24, 2026

README

google-play-scraper

npm version npm downloads CI Live contract tests API docs coverage types node license

📖 API reference — browsable, always-current typed signatures for every method, option and error class, generated from the source on each release.

Scrape Google Play app data in Node.js with a fully typed TypeScript API. Fetch app details, search results, top charts, developer pages, similar apps, user reviews, permissions and data safety information directly from the Play Store. Built for ASO research, app market analysis, competitor tracking, review monitoring and data pipelines.

This is a modern TypeScript rewrite of the popular but unmaintained google-play-scraper package. The public method names, options and constants match the original, so migrating is usually just a matter of swapping the import.

Why this library

  • Fully typed results. Every method returns a precise TypeScript type derived from a zod schema that validates the scraped data at runtime.
  • No HTTP dependency. Runs on the native fetch of Node.js 22 and newer.
  • Resilient by design. Every fragile Google Play array path lives behind a spec layer with ordered fallback paths, so a single moved index does not take a whole call down.
  • Verified against live Google Play daily. Contract tests run against play.google.com on a daily schedule in CI and open a labeled issue the moment Google changes its layout.
  • Typed errors. Branch on NotFoundError, RateLimitError or SpecError instead of parsing message strings.
  • Throttling, retries and caching included. Rate limiting, exponential backoff with Retry-After support and an optional memoized client come standard.
  • ESM and CommonJS. Ships both module formats plus type declarations from a single package.

Comparison with the original google-play-scraper

Capability@mradex77/google-play-scraperfacundoolano/google-play-scraper
LanguageTypeScript in strict modeJavaScript
Type definitionsGenerated from zod schemasCommunity typings
Runtime validationzod/mini on every input and output boundaryNone
Error handlingTyped error classesPlain Error
Module formatsESM and CommonJS with .d.tsESM only
Runtime dependencieszod, lru-cachecheerio, got, memoizee, ramda, ...
Breakage detectionDaily live contract tests in CINone
MaintenanceActively maintainedUnmaintained

Table of contents

Installation

npm install @mradex77/google-play-scraper

Requires Node.js 22.12 or newer.

Every release is published from GitHub Actions with npm provenance, so the package on npm is cryptographically linked to the exact commit and workflow that built it. Verify your installed tree with:

npm audit signatures

CLI

Every method with a JSON-friendly surface is also available from the command line, no install required:

npx @mradex77/google-play-scraper app com.spotify.music
npx @mradex77/google-play-scraper search "sleep tracker" --num 5 --country de
npx @mradex77/google-play-scraper reviews com.mojang.minecraftpe --sort rating --num 20
npx @mradex77/google-play-scraper availability com.adex77.WhereAmI --countries us,pl,de
CommandPositionalOwn flags
app <appId>app id
apps <appIds>comma-separated app ids--concurrency
search <term>search term--num, --price, --full-detail
suggest <term>search term
list--collection, --category, --age, --num, --full-detail
developer <devId>developer id--num, --full-detail
similar <appId>app id--full-detail
reviews <appId>app id--num, --sort, --paginate, --token
permissions <appId>app id--short
data-safety <appId>app id
categories
availability <appId>app id--countries (comma-separated, required), --concurrency

Every command also accepts --lang <code>, --country <code> and --throttle <requestsPerSecond>, plus --help/-h (global or per command) and the global --version.

Results are pretty-printed JSON on stdout, so the output pipes straight into jq:

npx @mradex77/google-play-scraper app com.spotify.music | jq '{title, score, installs}'

Exit codes: 0 success, 1 scrape failure (not found, rate limited, blocked, network), 2 usage error (unknown command or flag, missing argument, invalid option value).

Quick start

The package exposes named exports and an aggregate default export. Use whichever style you prefer.

import gplay from '@mradex77/google-play-scraper';

const app = await gplay.app({ appId: 'com.google.android.apps.translate' });
console.log(app.title, app.score);
import { app, type App } from '@mradex77/google-play-scraper';

const details: App = await app({ appId: 'com.google.android.apps.translate' });
console.log(details.installs);

CommonJS works too:

const gplay = require('@mradex77/google-play-scraper').default;

gplay.search({ term: 'panda' }).then((results) => {
  console.log(results.length);
});

More runnable examples live in examples/.

Common options

Every method accepts a single options object. These options are available on all of them:

OptionTypeDefaultDescription
langstring'en'Two letter language code used to fetch the page.
countrystring'us'Two letter country code. Needed for apps available only in some regions.
throttlenumbernoneMaximum requests per second across a single call.
requestOptionsobjectnoneHTTP overrides. See Throttling and requestOptions.
onDegradationfunctionnoneCallback fired when a call degrades gracefully. See Monitoring drift.
onIntegrityEventfunctionnoneCallback fired when routing, parsing or pagination needs attention. See Monitoring drift.

Shared client

The top-level functions each build their own HTTP client, so a throttle passed to one call only limits the requests made inside that single call, such as the pagination pages of one search(), list(), developer(), or reviews(). Ten parallel app() calls with throttle: 1 each get their own limiter and still reach Google Play at the same time.

createClient fixes this by sharing one rate limiter, one set of request options, and one pair of lang/country defaults across every method of the client:

import { createClient } from '@mradex77/google-play-scraper';

const client = createClient({ country: 'pl', lang: 'pl', throttle: 5 });

const ids = ['com.foo', 'com.bar', 'com.baz'];
const details = await Promise.all(ids.map((appId) => client.app({ appId })));

That whole batch never exceeds five requests per second in total, across every method of the client, including the fullDetail app lookups made by search, list, developer, and similar.

Precedence rules:

  1. A client-level throttle creates one shared limiter that every method call goes through, including pagination pages and fullDetail lookups.
  2. A per-call throttle passed to a client method is superseded by the client limiter. When the client was created without throttle, a per-call value behaves exactly like the top-level functions do today (a call-scoped limiter).
  3. requestOptions merge shallowly, with per-call keys winning: { ...client, ...call }.
  4. Per-call lang and country win over the client defaults; the built-in en/us defaults apply last.
  5. A client-level onDegradation or onIntegrityEvent applies to every call, including the fullDetail and batch lookups the client makes on your behalf. A per-call callback replaces the client-level one for that call; callbacks do not chain.

Every method from the reference below is available on the client, alongside the exported constants. Two clients created with createClient are fully independent and never share a limiter. memoized accepts the same options and adds an LRU cache on top.

A per-call throttle on the top-level functions only rate-limits the requests within that single call, such as its pagination pages. Reach for createClient when you need one limit to span many calls.

Methods

  • app: full detail of a single application
  • search: apps matching a search term
  • suggest: search term autocompletions
  • list: a ranked collection of apps
  • developer: other apps by the same developer
  • similar: apps related to a given app
  • reviews: user reviews for an app
  • permissions: permissions an app requests
  • dataSafety: the data safety section of an app
  • categories: the Google Play category taxonomy
  • memoized: a shared client that caches equivalent calls

app

Retrieves the full detail of an application.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id (the ?id= url parameter).
import { app } from '@mradex77/google-play-scraper';

const details = await app({ appId: 'com.google.android.apps.translate' });

Returns an App with 55 fields. Trimmed:

{
  title: 'Google Translate',
  description: 'Translate between up to 133 languages...',
  descriptionHTML: 'Translate between up to 133 languages...<br>...',
  summary: 'Instantly translate text, speech and images in over 100 languages',
  installs: '1,000,000,000+',
  minInstalls: 1000000000,
  score: 4.48,
  scoreText: '4.5',
  ratings: 8765432,
  reviews: 5678901,
  histogram: { '1': 123456, '2': 45678, '3': 90123, '4': 234567, '5': 4567890 },
  price: 0,
  free: true,
  currency: 'USD',
  priceText: 'Free',
  available: true,
  offersIAP: false,
  androidVersion: '8.0',
  androidVersionText: '8.0 and up',
  developer: 'Google LLC',
  developerId: '5700313618786177705',
  developerEmail: 'apps-help@google.com',
  developerWebsite: 'http://support.google.com/translate',
  genre: 'Tools',
  genreId: 'TOOLS',
  categories: [{ name: 'Tools', id: 'TOOLS' }],
  icon: 'https://play-lh.googleusercontent.com/...',
  screenshots: ['https://play-lh.googleusercontent.com/...'],
  contentRating: 'Everyone',
  adSupported: false,
  updated: 1719878400000,
  version: 'Varies with device',
  comments: [],
  appId: 'com.google.android.apps.translate',
  url: 'https://play.google.com/store/apps/details?id=com.google.android.apps.translate'
}

A listing that has not been released yet carries no offer at all. For those apps preregister is true, and price, free and priceText fall back to 0, false and 'Free' rather than describing a real offer. Rating, install and release fields are absent too. Read preregister before trusting any price field:

const details = await app({ appId: 'com.ironhidegames.android.kingdomrush6.genesis' });

if (details.preregister) {
  console.log(`${details.title} has no price yet`);
}

The same applies to a search() row for an unreleased app: it is returned with price: 0 and free: false, so a price: 'free' filter will not match it.

Batch details

Fetches the full detail of many apps in one call, with a concurrency limit and per-id failure capture. One missing app never rejects the whole batch, and results stay aligned with the input order.

OptionTypeDefaultDescription
appIdsstring[]requiredBetween 1 and 250 Google Play ids.
concurrencynumber5Maximum number of app lookups in flight at once, from 1 to 20.
import { apps } from '@mradex77/google-play-scraper';

const result = await apps({
  appIds: ['com.whatsapp', 'com.spotify.music', 'com.gone.app'],
  concurrency: 5,
});

Each entry is a discriminated union keyed on status; narrow on it to read the parsed App or the typed error:

for (const entry of result) {
  if (entry.status === 'fulfilled') {
    console.log(entry.appId, entry.app.title);
  } else {
    console.error(entry.appId, entry.error.name);
  }
}
type AppsEntry =
  | { appId: string; status: 'fulfilled'; app: App }
  | { appId: string; status: 'rejected'; error: GooglePlayError };

apps does not deduplicate appIds within a call: a list with the same id twice fetches it twice. Use a memoized client when you want repeated ids, across or within batches, served from cache.

concurrency bounds parallelism; a client throttle bounds the request rate. When both apply, the effective rate is the smaller of the two constraints, so createClient({ throttle: 2 }).apps({ appIds, concurrency: 5 }) still issues at most two requests per second.

Country availability

Answers "in which countries is this app published?" by probing the Google Play details page once per country. Each probe is a plain GET whose HTTP status is the whole answer, so the body is never parsed and a probe is an order of magnitude cheaper than an app lookup. No other scraper offers this as a first-class call.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id to probe.
countriesstring[]requiredBetween 1 and 50 ISO 3166-1 alpha-2 codes, case insensitive.
langstring'en'The hl language of each probe.
concurrencynumber5Maximum number of probes in flight at once, from 1 to 20.
import { availability } from '@mradex77/google-play-scraper';

const result = await availability({
  appId: 'com.adex77.WhereAmI',
  countries: ['us', 'pl', 'de', 'jp'],
});
{
  appId: 'com.adex77.WhereAmI',
  countries: {
    us: { status: 'available' },
    pl: { status: 'available' },
    de: { status: 'available' },
    jp: { status: 'unavailable' },
  },
}

Each country resolves to one of three statuses:

  • available — the probe returned 200; the app is published in that storefront.
  • unavailable — the probe returned 404; the app is not published there.
  • error — the probe could not tell: a block (consent wall or captcha), a rate limit, any other HTTP failure, or a network error, with the original message preserved.

error is deliberately never collapsed into unavailable. A blocked or rate-limited probe means "we could not determine availability", which must not masquerade as "not published" in data used for release decisions. Country codes are returned lowercased, duplicates (ignoring case) are rejected, and a single call probes at most 50 countries.

Because Google Play geoblocking depends on the caller's IP as much as on the gl parameter, pair availability with createCountryFetch so each probe exits through a proxy located in the country it probes:

import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici';
import { availability, createCountryFetch } from '@mradex77/google-play-scraper';

const throughProxy = (proxyUrl: string): typeof fetch => {
  const dispatcher = new ProxyAgent(proxyUrl);
  return ((input: string | URL, init?: RequestInit) =>
    undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;
};

const fetchImpl = createCountryFetch({
  perCountry: {
    us: throughProxy('http://us-proxy.internal:8080'),
    de: throughProxy('http://de-proxy.internal:8080'),
  },
});

const result = await availability({
  appId: 'com.adex77.WhereAmI',
  countries: ['us', 'de'],
  requestOptions: { fetchImpl },
});

Each probe carries gl=<country>, so createCountryFetch routes the us probe through the US proxy and the de probe through the German proxy.

Retrieves apps that match a search term.

OptionTypeDefaultDescription
termstringrequiredThe search query.
numnumber20Number of results, up to 250. Best-effort above the Google cap, see below.
price'all' | 'free' | 'paid''all'Filter results by price.
fullDetailbooleanfalseWhen true, fetch and return the full App for each result.
import { search } from '@mradex77/google-play-scraper';

const results = await search({ term: 'panda', num: 5 });

Returns SearchResult[] (or App[] when fullDetail is true). Trimmed:

[
  {
    title: 'Panda VPN',
    appId: 'com.example.pandavpn',
    url: 'https://play.google.com/store/apps/details?id=com.example.pandavpn',
    icon: 'https://play-lh.googleusercontent.com/...',
    developer: 'Panda Labs',
    developerId: '1234567890',
    currency: 'USD',
    price: 0,
    free: true,
    summary: 'Fast and secure VPN',
    scoreText: '4.3',
    score: 4.3,
  },
];

Google Play currently serves only the first result page, roughly 30 apps and fewer for narrow terms, and provides no continuation token (verified July 2026). A num above that cap is best-effort: the returned array may be shorter than requested.

suggest

Given a partial term, returns up to five search completions.

OptionTypeDefaultDescription
termstringrequiredThe partial search query.
import { suggest } from '@mradex77/google-play-scraper';

const suggestions = await suggest({ term: 'pand' });

Returns string[]:

['panda', 'pandora', 'panda vpn', 'panda pop', 'pandora music'];

list

Retrieves a ranked collection of apps, optionally scoped to a category and an age bracket.

OptionTypeDefaultDescription
collectionCollectioncollection.TOP_FREEOne of TOP_FREE, TOP_PAID, GROSSING.
categoryCategorycategory.APPLICATIONAny category constant.
ageAgenoneOne of age.FIVE_UNDER, age.SIX_EIGHT, age.NINE_UP.
numnumber500Number of results.
fullDetailbooleanfalseWhen true, return the full App for each result.
import { list, collection, category } from '@mradex77/google-play-scraper';

const items = await list({
  collection: collection.TOP_FREE,
  category: category.GAME,
  num: 5,
});

Returns ListItem[] (or App[] when fullDetail is true), each shaped like a search result.

Google Play serves at most roughly 200 list items per request (verified July 2026), so a num above that ceiling, including the default 500, returns fewer items than requested.

developer

Returns other apps published by the same developer. The devId is either the numeric developer id or the developer name, exactly as it appears on Google Play.

OptionTypeDefaultDescription
devIdstringrequiredNumeric developer id or developer name.
numnumber60Number of results.
fullDetailbooleanfalseWhen true, return the full App for each result.
import { developer } from '@mradex77/google-play-scraper';

const apps = await developer({ devId: '5700313618786177705' });

Returns DeveloperApp[] (or App[] when fullDetail is true), each shaped like a search result.

similar

Returns apps related to a given app.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the reference app.
fullDetailbooleanfalseWhen true, return the full App for each result.
import { similar } from '@mradex77/google-play-scraper';

const apps = await similar({ appId: 'com.google.android.apps.translate' });

Returns SimilarApp[] (or App[] when fullDetail is true), each shaped like a search result.

reviews

Retrieves reviews for an app. Reviews always come back inside a { data, nextPaginationToken } envelope so paging is uniform.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the app.
sortSortsort.NEWESTOne of sort.NEWEST, sort.RATING, sort.HELPFULNESS.
numnumber150Number of reviews to fetch.
paginatebooleanfalseWhen true, fetch a single page and return its token.
nextPaginationTokenstringnoneContinue from a token returned by a previous call.
import { reviews, sort } from '@mradex77/google-play-scraper';

const first = await reviews({ appId: 'com.google.android.apps.translate', sort: sort.NEWEST });

if (first.nextPaginationToken) {
  const next = await reviews({
    appId: 'com.google.android.apps.translate',
    paginate: true,
    nextPaginationToken: first.nextPaginationToken,
  });
}

Returns ReviewsResult. Trimmed:

{
  data: [
    {
      id: 'gp:AOqpTOH...',
      userName: 'Ada Lovelace',
      userImage: 'https://play-lh.googleusercontent.com/...',
      date: '2026-06-30T12:00:00.000Z',
      score: 5,
      title: null,
      text: 'Works offline and the camera translation is great.',
      thumbsUp: 42,
      version: '8.9.0',
      criterias: []
    }
  ],
  nextPaginationToken: 'CqYBCqMB...'
}

permissions

Returns the permissions an app requests.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the app.
shortbooleanfalseWhen true, return a flat string[] of common permission names.
import { permissions } from '@mradex77/google-play-scraper';

const detailed = await permissions({ appId: 'com.google.android.apps.translate' });
const names = await permissions({ appId: 'com.google.android.apps.translate', short: true });

Returns AppPermission[] (or string[] when short is true):

[
  { permission: 'take pictures and videos', type: 0 },
  { permission: 'view network connections', type: 1 },
];

The type is permission.COMMON (0) or permission.OTHER (1).

dataSafety

Returns the data safety section of an app.

OptionTypeDefaultDescription
appIdstringrequiredThe Google Play id of the app.
import { dataSafety } from '@mradex77/google-play-scraper';

const safety = await dataSafety({ appId: 'com.google.android.apps.translate' });

Returns DataSafety. Trimmed:

{
  sharedData: [
    { data: 'Approximate location', optional: false, purpose: 'App functionality', type: 'Location' }
  ],
  collectedData: [
    { data: 'Email address', optional: false, purpose: 'Account management', type: 'Personal info' }
  ],
  securityPractices: [
    { practice: 'Data is encrypted in transit', description: 'Your data is transferred over a secure connection' }
  ],
  privacyPolicyUrl: 'https://policies.google.com/privacy'
}

categories

Returns the Google Play category taxonomy as a list of category ids.

OptionTypeDefaultDescription
throttlenumbernoneSee common options.
requestOptionsobjectnoneSee common options.
import { categories } from '@mradex77/google-play-scraper';

const ids = await categories();

Returns string[]:

[
  'APPLICATION',
  'ANDROID_WEAR',
  'ART_AND_DESIGN',
  'AUTO_AND_VEHICLES',
  'GAME',
  'GAME_ACTION',
  'FAMILY',
];

memoized

Returns a shared client whose promise-returning methods share an LRU cache held in memory, so equivalent calls made within the TTL resolve from cache instead of hitting Google Play again. It accepts every createClient option plus the two cache bounds:

OptionTypeDefaultDescription
langstring'en'Language default applied to every call that does not set its own.
countrystring'us'Country default applied to every call that does not set its own.
throttlenumbernoneOne shared limiter for every request the client makes, cache misses included.
requestOptionsobjectnoneClient-level HTTP overrides, merged under per-call ones. See requestOptions.
onDegradationfunctionnoneClient-level degradation callback. See Monitoring drift.
onIntegrityEventfunctionnoneClient-level integrity callback. See Monitoring drift.
maxAgeMsnumber300000Time to live per cache entry, in milliseconds, counted from the moment the filling request completes. A positive integer up to 2147483647.
maxnumber1000Maximum number of cached entries. A positive integer up to 1000000.
import { memoized } from '@mradex77/google-play-scraper';

const client = memoized({ country: 'pl', lang: 'pl', throttle: 5, maxAgeMs: 60000, max: 500 });

await client.app({ appId: 'com.google.android.apps.translate' });
await client.app({ appId: 'com.google.android.apps.translate' });

Invalid options throw ValidationError before any cache or HTTP state exists.

What counts as the same call. Entries are keyed by the options after validation and defaulting, so these all share one entry: {} and { lang: 'en', country: 'us' }, country: 'US' and country: 'us', and the same options in any property order. Anything that changes the response or how it is obtained separates entries: lang, country, every feature option, and the headers, timeoutMs, retries, fetchImpl and signal fields of requestOptions (the last two by object identity, so two callers sharing a signal share an entry and one caller's abort never rejects an unrelated caller; a fresh signal on every call therefore never hits, so prefer timeoutMs for per-call deadlines). Pacing and telemetry never fragment the cache: throttle, concurrency, onDegradation, onIntegrityEvent, onRequest, onResponse and onRetry are ignored by the key, so inline callbacks are fine.

What is cached. app, availability, search, suggest, list, categories, developer, similar, reviews, permissions and dataSafety. Concurrent equivalent calls share one in-flight request. A hit resolves to the same object the filling call returned, so treat cached results as read-only. A rejected call is never cached, so the next call retries. apps and the fullDetail lookups of search, list, developer and similar are served from the app entries they warm. reviewsIterator, searchIterator, developerIterator, reviewsAll and apps are not cached themselves; they share the client's defaults, limiter and request options and pass through to Google Play.

Cache hits replay their events. Degradation and integrity events recorded while an entry was fetched are delivered again, in their original order, to the callbacks of every later call that hits the entry. A hit performs no request, so onRequest, onResponse and onRetry are not replayed. A replayed callback that throws rejects only the call it was passed to; the entry stays valid for other callers. Events recorded before a call fails are still delivered to every caller that shared the failed request. Events raised by the app lookups a fullDetail call or an apps batch makes on your behalf belong to that outer call: they reach its callbacks, and a cached fullDetail result replays them with its entry.

Cache controls. The client exposes explicit control without global state:

client.cache.size;
client.cache.invalidate('app', { appId: 'com.google.android.apps.translate' });
client.cache.clear();

invalidate takes the method name and the same options you would pass to that method, applies the client defaults and validation to them (throwing ValidationError for options the method itself would reject), and drops the matching entry, returning whether one existed. The next equivalent call requests again while unrelated entries survive. size counts live entries and falls as entries expire or are evicted.

Streaming and bulk reads

reviews(), search(), and developer() collect a fixed num of items before they resolve. When you want to walk results lazily and stop exactly when you have seen enough, use the async iterators. They fetch nothing until the first for await, and they stop fetching the moment the consumer breaks, so "first ten reviews then stop" costs exactly one page request.

The iterators are available both as standalone functions and as methods on createClient, where they share the client's limiter and defaults.

reviewsIterator

Streams reviews one at a time across pages.

import { reviewsIterator } from '@mradex77/google-play-scraper';

for await (const review of reviewsIterator({ appId: 'com.whatsapp' })) {
  if (review.score < 3) {
    break;
  }
  console.log(review.userName, review.score);
}

Pass nextPaginationToken to resume a stream where a previous run left off. The token comes from a prior reviews({ paginate: true }) call or from persisted state:

const page = await reviews({ appId: 'com.whatsapp', paginate: true });

const resumed = reviewsIterator({
  appId: 'com.whatsapp',
  nextPaginationToken: page.nextPaginationToken ?? undefined,
});

reviewsAll

Drains reviewsIterator into an array. Popular apps hold millions of reviews, so pass maxReviews to cap the read; the generator never fetches a page beyond the one that contains the last item you asked for. Combine it with a client throttle to stay within Google Play's rate limits.

import { createClient } from '@mradex77/google-play-scraper';

const client = createClient({ throttle: 5 });
const recent = await client.reviewsAll({ appId: 'com.whatsapp', maxReviews: 2000 });

Without maxReviews, reviewsAll walks the entire review history, which can be very large.

searchIterator and developerIterator

import { searchIterator, developerIterator } from '@mradex77/google-play-scraper';

for await (const result of searchIterator({ term: 'geography quiz' })) {
  console.log(result.appId);
}

for await (const item of developerIterator({ devId: 'Google LLC' })) {
  console.log(item.title);
}

Non-goals

  • No listIterator. list() sends a single batchexecute RPC that returns up to num items in one response; there is no server-side pagination token to iterate. Faking an iterator over one response would misrepresent the network behavior.
  • Iterators do not accept fullDetail or num. Streaming consumers call client.app() per item when they need full details, and the consumer controls the end by breaking; baking N+1 lookups or a fixed count into a generator hides its real cost. Use reviewsAll({ maxReviews }) for the bounded case.

Constants

The library exports the same constant sets as the original, frozen and typed.

ConstantValues
categoryAll app and game categories plus the FAMILY set (e.g. APPLICATION, TOOLS, GAME, GAME_PUZZLE, FAMILY).
collectionTOP_FREE, TOP_PAID, GROSSING.
sortNEWEST (2), RATING (3), HELPFULNESS (1).
ageFIVE_UNDER ('AGE_RANGE1'), SIX_EIGHT ('AGE_RANGE2'), NINE_UP ('AGE_RANGE3').
permissionCOMMON (0), OTHER (1).
import { category, collection, sort, age, permission } from '@mradex77/google-play-scraper';

Error handling

Every failure surfaces as a typed subclass of GooglePlayError, so you can branch on the exact cause instead of parsing message strings.

ErrorExtendsThrown when
GooglePlayErrorErrorBase class for every error the library throws.
ValidationErrorGooglePlayErrorThe options you passed fail their zod schema.
HttpErrorGooglePlayErrorA request fails with an unsuccessful status or a network error. Carries status and url.
NotFoundErrorHttpErrorGoogle Play responds 404, e.g. an unknown appId.
RateLimitErrorHttpErrorGoogle Play responds 429 after retries are exhausted.
BlockedErrorGooglePlayErrorA consent wall redirect, or a captcha challenge redirect that retries cannot clear.
ParseErrorGooglePlayErrorA batchexecute response cannot be parsed.
SpecErrorParseErrorExtraction fails; lists every failing field and the paths that were tried.
import { app, NotFoundError, SpecError } from '@mradex77/google-play-scraper';

try {
  const details = await app({ appId: 'com.does.not.exist' });
} catch (error) {
  if (error instanceof NotFoundError) {
    console.error('No such app');
  } else if (error instanceof SpecError) {
    console.error('Google Play changed its layout:', error.failures);
  } else {
    throw error;
  }
}

Failure behavior

An unknown appId or devId does not fail the same way everywhere, because Google Play itself does not: HTML details surfaces respond 404, while report surfaces serve an empty payload. Both behaviors are pinned by the live test suite.

Behavior for a missing appMethods
Throws NotFoundErrorapp, apps (per entry, as a rejected PromiseSettledResult), similar, developer
Resolves with a typed empty valuesearch and suggest ([]), reviews ({ data: [], nextPaginationToken: null }), permissions ([]), dataSafety (empty arrays, no privacyPolicyUrl)
Maps the throw to a statusavailability reports unavailable

Typed empties are limited to the documented response shapes above and other schema-backed empty variants. A structurally missing or malformed response root throws ParseError; a missing required field inside a valid root throws SpecError. Neither case is converted into an empty result, 0, false, Free, or VARY unless that exact fallback is part of the documented field contract.

Throttling and requestOptions

Pass throttle to cap requests per second, and requestOptions to override the HTTP layer:

requestOptions fieldTypeDescription
headersRecord<string, string>Extra headers merged into every request.
fetchImpltypeof fetchA custom fetch implementation, useful for proxies and tests. Combine with createCountryFetch to route each storefront country separately.
timeoutMsnumberTimeout per request, up to 120000. Default 30000.
retriesnumberRetry count for 429, 5xx and captcha challenge redirects, 0 to 5. Default 2.
signalAbortSignalCancels the call at once, including an in-flight request, a body read, retry backoff, a Retry-After wait and a queued throttle slot. See Cancellation.
onRequest(event: RequestEvent) => voidCalled before every attempt, including retries. See Request lifecycle hooks.
onResponse(event: ResponseEvent) => voidCalled for each settled response — after the body is read on success — with status and durationMs.
onRetry(event: RetryEvent) => voidCalled when a retry is scheduled, with delayMs and reason.
import { app } from '@mradex77/google-play-scraper';

const details = await app({
  appId: 'com.google.android.apps.translate',
  throttle: 5,
  requestOptions: {
    timeoutMs: 15000,
    retries: 3,
    headers: { 'Accept-Language': 'de' },
    fetchImpl: myProxiedFetch,
  },
});

Retries use exponential backoff with jitter. A Retry-After response header overrides the backoff when it is well formed, in either form RFC 9110 allows: a count of seconds, or an HTTP date, which is resolved against the response Date header when the server sends one and against the local clock otherwise.

A Retry-After longer than 60 seconds is not waited on. The call ends immediately with the mapped status error, RateLimitError for a 429 or BlockedError for a captcha challenge redirect, so a server cannot park a request for minutes. The ceiling is fixed and independent of timeoutMs, which bounds a single attempt. A malformed, past or negative value is ignored and normal jittered backoff applies.

Request lifecycle hooks

The three on* callbacks make request telemetry observable at every level — direct calls, createClient, and memoized — including what wrapping fetchImpl cannot see: retries and backoff decisions.

import { app } from '@mradex77/google-play-scraper';

const details = await app({
  appId: 'com.google.android.apps.translate',
  requestOptions: {
    onRequest: (event) => log.debug('gplay request', event),
    onResponse: (event) => metrics.timing('gplay.latency', event.durationMs),
    onRetry: (event) => metrics.increment('gplay.retry', { reason: event.reason }),
  },
});

Every event carries url, method, and a 1-based attempt: the first try is attempt 1, a RetryEvent carries the number of the attempt that just failed, and the subsequent RequestEvent is that attempt plus one. ResponseEvent adds status and durationMs, measured from just before the fetch (throttle wait excluded) and, on success, through the transfer of the full body. RetryEvent adds the scheduled delayMs, a reason of 'status' or 'network', and the HTTP status when the reason is a retryable status. A retry-free happy path emits exactly one RequestEvent and one ResponseEvent.

Hooks are telemetry, never control flow. A hook that throws or returns a rejecting promise is swallowed and the request proceeds unchanged — the deliberate opposite of onDegradation and onIntegrityEvent, which rethrow because they are data-integrity signals. When a client-level and a call-level requestOptions set the same hook, the call-level one replaces it for that call; hooks do not chain.

Routing requests through a proxy

The native fetch of Node.js ignores the HTTP_PROXY and HTTPS_PROXY environment variables by default. On Node.js 24 and newer, opt in when launching your process:

NODE_USE_ENV_PROXY=1 node app.js

For per-call control, or on Node.js 22, inject a proxied fetch from undici through fetchImpl:

import { ProxyAgent, fetch as proxiedFetch, type RequestInit } from 'undici';
import { app } from '@mradex77/google-play-scraper';

const dispatcher = new ProxyAgent('http://user:password@proxy.example.com:8080');

const fetchImpl = ((input: string | URL, init?: RequestInit) =>
  proxiedFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;

const details = await app({
  appId: 'com.google.android.apps.translate',
  requestOptions: { fetchImpl },
});

ProxyAgent also accepts an options object when the proxy needs more configuration, such as a token carrying a preformatted Proxy-Authorization header or TLS settings for the proxy connection.

Rotating proxies when blocked

Google answers a burst of requests from one IP address with a redirect to its captcha challenge at www.google.com/sorry. The client retries that redirect like a 429, and most challenges clear on the next attempt. When they keep coming until retries run out, or the challenge asks to wait longer than the Retry-After ceiling, the call rejects with a BlockedError whose message names the cause (captcha challenge or consent wall), which is the signal to slow down or switch to another exit IP:

import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici';
import { BlockedError, createClient } from '@mradex77/google-play-scraper';

const throughProxy = (proxyUrl: string): typeof fetch => {
  const dispatcher = new ProxyAgent(proxyUrl);
  return ((input: string | URL, init?: RequestInit) =>
    undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;
};

const routes = [
  'http://user:password@proxy-1.example.com:8080',
  'http://user:password@proxy-2.example.com:8080',
].map((proxyUrl) =>
  createClient({ throttle: 2, requestOptions: { fetchImpl: throughProxy(proxyUrl) } }),
);

async function searchWithRotation(term: string) {
  for (const client of routes) {
    try {
      return await client.search({ term });
    } catch (error) {
      if (!(error instanceof BlockedError)) {
        throw error;
      }
    }
  }
  throw new Error('Every proxy is blocked');
}

The challenge is driven by request rate per IP, so each route gets its own createClient whose limiter spans every call through that proxy, concurrent ones included, and prevents most blocks before rotation is needed. A per-call throttle on search would only pace the requests inside that one call. Detection reads only the final response URL, never page text, so a search term or review that quotes a captcha message is returned as data.

Routing by country

createCountryFetch builds a fetch implementation that picks a route per storefront country, so requests for each country option go through their own proxy. It reads the gl query parameter that Google Play requests carry, matches it against perCountry (ISO 3166-1 alpha-2, case insensitive), and falls back to fallback or the global fetch when no route matches:

import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici';
import { app, createCountryFetch } from '@mradex77/google-play-scraper';

const throughProxy = (proxyUrl: string): typeof fetch => {
  const dispatcher = new ProxyAgent(proxyUrl);
  return ((input: string | URL, init?: RequestInit) =>
    undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;
};

const fetchImpl = createCountryFetch({
  perCountry: {
    us: throughProxy('http://us-proxy.internal:8080'),
    de: throughProxy('http://user:password@de-proxy.internal:8080'),
  },
  fallback: throughProxy('http://default-proxy.internal:8080'),
});

const details = await app({
  appId: 'com.google.android.apps.translate',
  country: 'de',
  requestOptions: { fetchImpl },
});

Routes accept any typeof fetch, so the same helper also works for per-country rate limiting, logging or fixtures in tests. Omit fallback to send unmatched countries through a direct connection. The only request without a gl parameter is the dataSafety page fetch, which always uses the fallback route.

Cancellation

Pass an AbortSignal to cancel long running calls, such as a reviews fetch that walks many pages. An aborted call rejects with the signal's reason and is never retried:

import { reviews } from '@mradex77/google-play-scraper';

const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);

const result = await reviews({
  appId: 'com.google.android.apps.translate',
  num: 5000,
  requestOptions: { signal: controller.signal },
});

Cancellation is immediate at every point in the request lifecycle, not only while a request is on the wire:

Where the abort landsWhat happens
Before the call startsRejects without contacting Google Play; no onRequest event is emitted
During the request or the body readRejects with the signal's reason, not a wrapped HttpError
During retry backoff or a Retry-After waitRejects before the delay expires; no further attempt is made
While queued behind a throttle slotRejects at once; the slot is released and the other queued calls keep the configured rate

The reason you pass to abort() is the value that reaches you, by identity, so a sentinel object survives the round trip:

import { search } from '@mradex77/google-play-scraper';

const budgetExceeded = { reason: 'budget-exceeded' };
const controller = new AbortController();
setTimeout(() => controller.abort(budgetExceeded), 5000);

try {
  await search({ term: 'panda', num: 250, requestOptions: { signal: controller.signal } });
} catch (error) {
  if (error !== budgetExceeded) {
    throw error;
  }
}

Calling abort() with no argument produces the standard AbortError DOMException. A per-request timeout is separate: timeoutMs ends one attempt and may still be retried, while your own abort is terminal.

A custom fetchImpl should honor init.signal so an aborted request is cut short on the wire. One that ignores it cannot be interrupted mid-request, but the call still rejects with your reason as soon as that request settles, and its body is discarded.

Resilience

Google Play serves its data as deeply nested, unlabeled arrays whose positions shift a few times a year. That is what breaks scrapers. Every positional path in this library lives as a typed constant in a specs.ts file per feature, never inline in logic, and each field is resolved through an ordered list of candidate paths so a single moved index does not take the whole call down. Extraction collects all field failures in one pass and throws a single SpecError naming every broken field and the paths that were tried, which is exactly the input the maintenance runbook needs. Unknown data enters as unknown and only leaves through a zod schema, so a layout change fails loudly at the boundary rather than three layers up.

To catch breakage before users do, the e2e/ suite runs against live Google Play on a daily GitHub Actions schedule and opens a labeled issue on failure. Repairing a break is a spec diff confined to one file, walked through step by step in docs/RUNBOOK.md.

Monitoring drift

Cluster pagination degrades gracefully: when a continuation page from Google stops parsing, the call keeps everything collected so far and returns instead of throwing. The onDegradation callback makes every swallowed continuation failure observable. The separate onIntegrityEvent callback reports routing fallbacks, skipped best-effort sections, and pagination token cycles without widening the existing DegradationEvent contract.

The option exists on every method for uniformity, but only the methods that paginate through the cluster endpoint can emit: search, searchIterator, similar, developer, and developerIterator. Each event carries the feature context, the machine readable reason ('cluster-page-parse'), and the underlying ParseError:

import { search, type DegradationEvent } from '@mradex77/google-play-scraper';

const onDegradation = (event: DegradationEvent): void => {
  metrics.increment('gplay.degradation', {
    context: event.context,
    reason: event.reason,
  });
};

const results = await search({ term: 'panda', num: 100, onDegradation });

A degraded call still resolves with the pages that parsed, so wire the callback to a metrics counter or log sink rather than treating it as an error path. If the callback itself throws, the error surfaces to the caller unchanged.

Integrity events use the same shape but a separate type and callback:

import { app, type IntegrityEvent } from '@mradex77/google-play-scraper';

const onIntegrityEvent = (event: IntegrityEvent): void => {
  metrics.increment('gplay.integrity', {
    context: event.context,
    reason: event.reason,
  });
};

const details = await app({
  appId: 'com.google.android.apps.translate',
  onIntegrityEvent,
});
CallbackReasonMeaning
onDegradationcluster-page-parseA cluster continuation failed to parse and collected results returned.
onIntegrityEventrpc-anchor-fallbackAn RPC anchor used its validated absolute fallback.
onIntegrityEventoptional-section-parseA present best-effort section failed to parse and was skipped.
onIntegrityEventpagination-token-cycleA repeated token stopped pagination before a duplicate request.
onIntegrityEventsection-anchor-fallbackA best-effort section resolved outside its declared anchor.

Two boundaries to know:

  • An empty continuation page emits nothing: that is the normal end-of-results signal and is indistinguishable from exhaustion.
  • app emits optional-section-parse with context app comments when the available comment roots are structurally invalid. A valid empty comment root returns an empty list without an event. Treat a rising event rate as a signal, not each event.
  • reviews pagination never swallows a parse failure. Malformed review pages reject with ParseError, while a repeated token stops safely and emits pagination-token-cycle.

With memoized(), callbacks never affect which entry a call hits. Degradation and integrity events are recorded with the entry and replayed, in order, to the callbacks of every call that hits it, so a cached degraded result is reported as degraded to each caller. Lifecycle hooks are not replayed, because a hit performs no request. See memoized.

Versioning and API stability

This package follows Semantic Versioning. For a scraper the contract needs one clarification: semver covers the code surface this library controls, not the data Google serves.

Integrity diagnostics use the additive API choice (Option B): the existing DegradationEvent.reason remains exactly 'cluster-page-parse', and the four new reasons live on the opt-in onIntegrityEvent callback. This keeps exhaustive switches over DegradationEvent.reason and narrowly typed onDegradation handlers intact while adding observability without a major release. IntegrityReason itself is a widening union, so a new reason is a minor release and an exhaustive switch over it needs a new arm.

ChangeRelease
Removing or renaming an exported function, option, or constantmajor
Removing a field from a result schema, or changing its typemajor
Raising the Node.js support floor or dropping a module formatmajor
Adding a new method, option, or optional result fieldminor
Adding a reason to IntegrityReasonminor
Restoring extraction of a field after a Google Play layout changepatch

What semver cannot cover is the content behind those shapes. Google Play changes its markup a few times a year, and a field can start coming back undefined, empty, or degraded without any release of this package. The policy for that case:

  • The daily live test suite fails and an issue is filed automatically, usually before user reports arrive.
  • When extraction can be restored, the fix ships as a patch.
  • When Google removes the underlying data permanently, the field stays in the schema as a documented degraded surface first (see categories for the worked example) and is only removed in the next major.

To watch for drift in your own production use, wire up onDegradation and treat SpecError as a layout-change signal rather than an application bug.

Migrating from google-play-scraper

The method names, options, and constants are the same, so most code keeps working after swapping the import. Watch for these differences:

  • reviews always returns the { data, nextPaginationToken } envelope, never a bare array.
  • Dates are ISO 8601 strings (review date, replyDate), and updated is a millisecond timestamp.
  • Errors are the typed classes above instead of plain Error.
  • The package is ESM first with a CommonJS build; the default export is the aggregate client and named exports are also available.

Deriving from the exported schemas

Every exported schema is a zod/mini schema. .parse and .safeParse behave exactly like their classic zod counterparts, but transform methods such as .extend, .pick, and .omit live as top-level functions on the zod/mini entry of the same zod package:

import * as z from 'zod/mini';
import { appSchema } from '@mradex77/google-play-scraper';

const slimAppSchema = z.pick(appSchema, { title: true, appId: true, score: true });

The zod/mini surface is what keeps the library small in your bundle. Measured with esbuild --bundle --minify --format=esm --platform=node against zod 4.4.3:

BundleMinifiedGzip
Classic zod (v0.4)414.8 kB85.8 kB
zod/mini (current)115.1 kB29.9 kB

Install size on disk is unchanged by design: zod/mini is a subpath export of the same zod package, so node_modules stays identical while the consumer bundle shrinks about 3.6×.

FAQ

Is there an official Google Play API for app data?

No. Google does not offer a public API for store listings, search results or reviews. Libraries like this one fill that gap by scraping the public web pages and internal endpoints that power play.google.com. This is the standard approach for ASO tools, market research and academic studies.

How do I scrape Google Play reviews for an app?

Call reviews with the app id. Use paginate: true and the returned nextPaginationToken to walk through all pages. Combine it with throttle to stay under the rate limits.

How do I avoid getting rate limited or blocked?

Set the throttle option to cap requests per second, keep the default retry behavior, and reuse results through the memoized client. If you run large jobs, spread them out over time or route them through a proxy. A RateLimitError or BlockedError tells you exactly when Google started pushing back, and rotating proxies shows how to react to a block.

Does this library work in the browser?

No. It targets Node.js 22.12 and newer. Browsers cannot scrape Google Play anyway because of CORS restrictions.

Is this library affiliated with Google?

No. It is an independent open source project. All app data belongs to its respective owners, and you are responsible for using it in compliance with the Google Play Terms of Service and applicable law.

Contributing

Contributions are welcome. See CONTRIBUTING.md for the development setup, commit conventions and test workflow. Found a field that stopped resolving? Open an issue with the SpecError output, or follow docs/RUNBOOK.md to fix the spec yourself.

pnpm install
pnpm lint            eslint on the whole repo
pnpm typecheck       tsc --noEmit
pnpm test            unit tests, offline against recorded fixtures
pnpm test:coverage   unit tests with coverage thresholds
pnpm test:e2e        live contract tests against play.google.com
pnpm build           emit dist/ with esm, cjs, and d.ts
pnpm check:package   build then verify the published package

Disclaimer

This project is not affiliated with, endorsed by or sponsored by Google. It accesses publicly available data only. Use it responsibly, respect the Google Play Terms of Service, and throttle your requests.

License

MIT

android
api
app-data
app-reviews
app-store-optimization
aso
crawler
googe-play-api
google-play
google-play-scraper
market-research
nodejs
play-store
playstore
scraper
typescript
web-scraper

Contributors

MrAdex77

447 commits

dependabot[bot]

10 commits

s-h-a-d-o-w

5 commits

Languages

TypeScript

99.8%