npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mradex77/google-play-scraper

v1.0.0

Published

Google Play scraper for Node.js with a fully typed TypeScript API. Fetch app details, search results, top charts, reviews, permissions and data safety from the Play Store.

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-scraper | facundoolano/google-play-scraper | | -------------------- | ------------------------------------------- | ------------------------------------------ | | Language | TypeScript in strict mode | JavaScript | | Type definitions | Generated from zod schemas | Community typings | | Runtime validation | zod/mini on every input and output boundary | None | | Error handling | Typed error classes | Plain Error | | Module formats | ESM and CommonJS with .d.ts | ESM only | | Runtime dependencies | zod, lru-cache | cheerio, got, memoizee, ramda, ... | | Breakage detection | Daily live contract tests in CI | None | | Maintenance | Actively maintained | Unmaintained |

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

| Command | Positional | Own 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:

| Option | Type | Default | Description | | ---------------- | ---------- | ------- | ------------------------------------------------------------------------------------------ | | lang | string | 'en' | Two letter language code used to fetch the page. | | country | string | 'us' | Two letter country code. Needed for apps available only in some regions. | | throttle | number | none | Maximum requests per second across a single call. | | requestOptions | object | none | HTTP overrides. See Throttling and requestOptions. | | onDegradation | function | none | Callback fired when a call degrades gracefully. 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.

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.

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 client that caches identical calls

app

Retrieves the full detail of an application.

| Option | Type | Default | Description | | ------- | -------- | -------- | ---------------------------------------------- | | appId | string | required | The 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: '[email protected]',
  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'
}

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.

| Option | Type | Default | Description | | ------------- | ---------- | -------- | ------------------------------------------------------------------ | | appIds | string[] | required | Between 1 and 250 Google Play ids. | | concurrency | number | 5 | Maximum 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.

| Option | Type | Default | Description | | ------------- | ---------- | -------- | ---------------------------------------------------------------- | | appId | string | required | The Google Play id to probe. | | countries | string[] | required | Between 1 and 50 ISO 3166-1 alpha-2 codes, case insensitive. | | lang | string | 'en' | The hl language of each probe. | | concurrency | number | 5 | Maximum 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.

search

Retrieves apps that match a search term.

| Option | Type | Default | Description | | ------------ | --------------------------- | -------- | ---------------------------------------------------------------------------- | | term | string | required | The search query. | | num | number | 20 | Number of results, up to 250. Best-effort above the Google cap, see below. | | price | 'all' \| 'free' \| 'paid' | 'all' | Filter results by price. | | fullDetail | boolean | false | When 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.

| Option | Type | Default | Description | | ------ | -------- | -------- | ------------------------- | | term | string | required | The 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.

| Option | Type | Default | Description | | ------------ | ------------ | ---------------------- | -------------------------------------------------------- | | collection | Collection | collection.TOP_FREE | One of TOP_FREE, TOP_PAID, GROSSING. | | category | Category | category.APPLICATION | Any category constant. | | age | Age | none | One of age.FIVE_UNDER, age.SIX_EIGHT, age.NINE_UP. | | num | number | 500 | Number of results. | | fullDetail | boolean | false | When 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.

| Option | Type | Default | Description | | ------------ | --------- | -------- | --------------------------------------------------- | | devId | string | required | Numeric developer id or developer name. | | num | number | 60 | Number of results. | | fullDetail | boolean | false | When 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.

| Option | Type | Default | Description | | ------------ | --------- | -------- | --------------------------------------------------- | | appId | string | required | The Google Play id of the reference app. | | fullDetail | boolean | false | When 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.

| Option | Type | Default | Description | | --------------------- | --------- | ------------- | -------------------------------------------------------- | | appId | string | required | The Google Play id of the app. | | sort | Sort | sort.NEWEST | One of sort.NEWEST, sort.RATING, sort.HELPFULNESS. | | num | number | 150 | Number of reviews to fetch. | | paginate | boolean | false | When true, fetch a single page and return its token. | | nextPaginationToken | string | none | Continue 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.

| Option | Type | Default | Description | | ------- | --------- | -------- | ----------------------------------------------------------------- | | appId | string | required | The Google Play id of the app. | | short | boolean | false | When 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.

| Option | Type | Default | Description | | ------- | -------- | -------- | ------------------------------ | | appId | string | required | The 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.

| Option | Type | Default | Description | | ---------------- | -------- | ------- | -------------------------------------- | | throttle | number | none | See common options. | | requestOptions | object | none | See 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 client whose methods share an LRU cache held in memory, so identical calls made within the TTL resolve from cache instead of hitting Google Play again.

| Option | Type | Default | Description | | ---------- | -------- | -------- | ---------------------------------------------- | | maxAgeMs | number | 300000 | Time to live per cache entry, in milliseconds. | | max | number | 1000 | Maximum number of cached entries. |

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

const client = memoized({ maxAgeMs: 60000, max: 500 });

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

The returned client exposes every method above plus the exported constants.

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.

| Constant | Values | | ------------ | ----------------------------------------------------------------------------------------------------------------- | | category | All app and game categories plus the FAMILY set (e.g. APPLICATION, TOOLS, GAME, GAME_PUZZLE, FAMILY). | | collection | TOP_FREE, TOP_PAID, GROSSING. | | sort | NEWEST (2), RATING (3), HELPFULNESS (1). | | age | FIVE_UNDER ('AGE_RANGE1'), SIX_EIGHT ('AGE_RANGE2'), NINE_UP ('AGE_RANGE3'). | | permission | COMMON (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.

| Error | Extends | Thrown when | | ----------------- | ----------------- | ------------------------------------------------------------------------------------------- | | GooglePlayError | Error | Base class for every error the library throws. | | ValidationError | GooglePlayError | The options you passed fail their zod schema. | | HttpError | GooglePlayError | A request fails with an unsuccessful status or a network error. Carries status and url. | | NotFoundError | HttpError | Google Play responds 404, e.g. an unknown appId. | | RateLimitError | HttpError | Google Play responds 429 after retries are exhausted. | | BlockedError | GooglePlayError | A consent wall or captcha interstitial is detected. | | ParseError | GooglePlayError | A batchexecute response cannot be parsed. | | SpecError | ParseError | Extraction 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 app | Methods | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Throws NotFoundError | app, apps (per entry, as a rejected PromiseSettledResult), similar, developer | | Resolves with a typed empty value | search and suggest ([]), reviews ({ data: [], nextPaginationToken: null }), permissions ([]), dataSafety (empty arrays, no privacyPolicyUrl) | | Maps the throw to a status | availability reports unavailable |

Throttling and requestOptions

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

| requestOptions field | Type | Description | | -------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | headers | Record<string, string> | Extra headers merged into every request. | | fetchImpl | typeof fetch | A custom fetch implementation, useful for proxies and tests. Combine with createCountryFetch to route each storefront country separately. | | timeoutMs | number | Timeout per request, up to 120000. Default 30000. | | retries | number | Retry count for 429 and 5xx, 0 to 5. Default 2. | | signal | AbortSignal | Cancels the call, including in-flight retries and pagination. | | onRequest | (event: RequestEvent) => void | Called before every attempt, including retries. See Request lifecycle hooks. | | onResponse | (event: ResponseEvent) => void | Called for each settled response — after the body is read on success — with status and durationMs. | | onRetry | (event: RetryEvent) => void | Called 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 and honor a Retry-After header when present.

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, which rethrows because it is a data-integrity signal. 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:[email protected]: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.

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:[email protected]: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.

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 },
});

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. That is the right default for consumers, but it can hide the day Google changes its layout. The onDegradation callback makes every swallowed continuation failure observable.

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.

Two boundaries to know:

  • An empty continuation page emits nothing: that is the normal end-of-results signal and is indistinguishable from exhaustion.
  • reviews pagination never degrades. A parse failure there propagates as a ParseError, so review reads fail loudly instead of silently shrinking.

With memoized(), onDegradation and the lifecycle hooks onRequest, onResponse, and onRetry participate in the cache key by identity like any function option, so pass stable function references rather than inline closures to keep cache hits.

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.

| Change | Release | | ----------------------------------------------------------------- | ------- | | Removing or renaming an exported function, option, or constant | major | | Removing a field from a result schema, or changing its type | major | | Raising the Node.js support floor or dropping a module format | major | | Adding a new method, option, or optional result field | minor | | Restoring extraction of a field after a Google Play layout change | patch |

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:

| Bundle | Minified | Gzip | | ------------------ | -------- | ------- | | Classic zod (v0.4) | 414.8 kB | 85.8 kB | | zod/mini (current) | 115.1 kB | 29.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.

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.

Related projects

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