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.5.0

Published

Google Play scraper for Node.js and Cloudflare Workers 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 coverage node license

Scrape Google Play app data in Node.js and Cloudflare Workers with a fully typed TypeScript API. Fetch app details, search results, top charts, developer pages, similar apps, reviews, permissions and data safety reports straight from the Play Store, validated at runtime and returned as typed objects.

This is a TypeScript rewrite of the unmaintained google-play-scraper package. Thanks to facundoolano for the years of work on the original: without it this library would not exist. Method names, options and constants match the original, so most migrations are an import swap.

API reference · Examples · Changelog · Migration guide

If you need a hosted ASO tool rather than a library, AsoBeast tracks keyword ranks daily on Google Play and the App Store, watches competitor listings and alerts on changes, audits store listings with keyword ideas, and exposes a REST API and an MCP server. It is open source and self-hostable.

Highlights

  • Typed results. Every method returns a TypeScript type derived from the zod schema that validates the scraped data at runtime.
  • No HTTP dependency. Runs on the native fetch of Node.js 22.12 and newer and of Cloudflare Workers.
  • Survives layout changes. Every Google Play array path sits behind ordered fallback paths, so one moved index does not break a call.
  • Checked against live Google Play daily. Contract tests run in CI on a schedule and open a labeled issue when Google changes its layout.
  • Typed errors. Branch on NotFoundError, RateLimitError or SpecError instead of parsing message strings.
  • Throttling, retries and caching built in. Rate limiting, exponential backoff with Retry-After support and an optional memoized client.
  • ESM and CommonJS builds with type declarations from a single package.

Comparison with the original

| 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 | 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 | Active | Unmaintained |

Table of contents

Installation

npm install @mradex77/google-play-scraper

Requires Node.js 22.12 or newer, or a Cloudflare Worker.

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/.

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 reviews com.whatsapp --score 1 --device tablet --num 50
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, --score, --device, --page-size, --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 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). A malformed app id (see App ids) exits 2 with the usage line and sends no request. apps is the exception: it prints the malformed entry as rejected and exits 0, as it does for a missing app. Whitespace around the commas in apps is trimmed before validation, so apps "com.whatsapp, com.spotify.music" works.

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 | Called when a call degrades gracefully. See Monitoring drift. | | onIntegrityEvent | function | none | Called when routing, parsing or pagination needs attention. See Monitoring drift. |

Shared client

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

createClient shares 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 batch never exceeds five requests per second in total, including the fullDetail app lookups made by search, list, developer and similar.

Precedence rules:

  1. A client-level throttle is one shared limiter for every call, including pagination pages and fullDetail lookups.
  2. A per-call throttle on a client method is superseded by the client limiter. Without a client throttle, it creates a call-scoped limiter, as on the top-level functions.
  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 lookups the client makes for you. A per-call callback replaces it for that call; callbacks do not chain.

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

Methods

  • app: full detail of a single application
  • apps: full detail of many applications in one call
  • availability: the countries an app is published in
  • 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 ids

Every appId is validated before any request, on every method, iterator, client and CLI command. It must be an Android package name: at least two dot-separated segments, each starting with a letter and containing only letters, digits and underscores, at most 255 characters in total. Case is significant (com.adex77.WhereAmI and com.adex77.whereami are different ids on Google Play), so ids are never lowercased. Leading or trailing whitespace is rejected rather than trimmed, so the id you pass is the id that is requested and echoed back. The rule follows the Android application ID rules.

A malformed id throws ValidationError without reaching Google Play. The one exception is apps, which reports that entry as rejected with a ValidationError, the same way it reports a missing app, and sends no request for it. An empty string in appIds still fails validation of the whole call.

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

try {
  await app({ appId: 'com.whatsapp ' });
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(error.message);
  }
}

app

Retrieves the full detail of an application.

| Option | Type | Default | Description | | ------- | -------- | -------- | ----------------------------------------------------------------------- | | appId | string | required | The Google Play id (the ?id= url parameter), see App ids. |

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

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

Returns an App with 59 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',
  summaryHTML: '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',
  recentChanges: 'Bug fixes and improvements.\nNew offline languages',
  recentChangesHTML: 'Bug fixes and improvements.<br>New offline languages',
  comments: [],
  appId: 'com.google.android.apps.translate',
  url: 'https://play.google.com/store/apps/details?id=com.google.android.apps.translate'
}

description, summary and recentChanges are plain text: tags are removed, the entities Google uses (&amp;, &lt;, &gt;, &quot;, &apos;, &nbsp; and numeric references) are decoded and each <br> becomes a newline. Escape these fields like any other text before inserting them into HTML.

Each of them has a markup twin read from the same Google cell: descriptionHTML, summaryHTML and recentChangesHTML keep the tags and entities as Google serves them (control characters removed), and a twin is present exactly when its plain text field is. The markup is written by the app developer and filtered by Google, so pass it through your own HTML sanitizer before inserting it into a page.

A listing that has not been released yet carries no offer. For those apps preregister is true, and price, free and priceText fall back to 0, false and 'Free' without 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`);
}

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

apps

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. Only library failures are captured: a cancelled call rejects with your abort reason, and a throwing callback rejects with its own error.

| Option | Type | Default | Description | | ------------- | ---------- | -------- | ---------------------------------------------------------------------------------- | | appIds | string[] | required | Between 1 and 250 Google Play ids, see App ids for malformed ones. | | 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: a list with the same id twice fetches it twice. Use a memoized client to serve repeated ids from cache.

concurrency and a client throttle are independent limits: concurrency bounds the lookups in flight, throttle bounds how many requests start per second. With createClient({ throttle: 2 }).apps({ appIds, concurrency: 5 }), up to five lookups can be in flight while at most two requests start per second.

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 costs far less than an app lookup.

| Option | Type | Default | Description | | ------------- | ---------- | -------- | ---------------------------------------------------------------- | | appId | string | required | The Google Play id to probe, see App ids. | | 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.

A cancelled call rejects with your abort reason instead of reporting countries as error. error is never collapsed into unavailable: a blocked or rate-limited probe means "could not determine", which must not read 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.

Google Play geoblocking depends on the caller's IP as much as on the gl parameter, so pair availability with createCountryFetch. Each probe carries gl=<country>, so a per-country route sends the us probe through a US proxy and the de probe through a German one.

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 first page, 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',
    summaryHTML: 'Fast and secure VPN',
    scoreText: '4.3',
    score: 4.3,
  },
];

The summary of a search result, like the summary of every list, developer and similar item, is plain text. Google fills that cell with the app description markup (often thousands of characters), and the library strips the tags, decodes the entities and turns each <br> into a newline. summaryHTML carries the same cell with its markup, for callers that render HTML (sanitize it first, as for app).

Google Play serves a first result page of roughly 20 to 30 apps (50 for single character terms), and fewer for narrow terms. For a minority of queries it also serves a continuation token (verified October 2026: single character terms and digits, and some app names such as minecraft or reddit), and search() and searchIterator() follow it until Google ends the results, measured at no more than 250 in total. Continuation results are looser matches than the first page and carry currency only for paid apps. For every other term the first page is all Google serves, so a num above it is best-effort: the returned array may be shorter than requested, never longer, and never repeats an app.

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.

The developerId that app returns (and the one a search exact match carries) is already in this form, so it can be passed straight back: a numeric id for developers Google links by number, otherwise the plain developer name such as WhatsApp LLC, H&M or Moon+. It is the decoded name, never the form encoded query value (WhatsApp+LLC, H%26M) that the Google Play link holds; the library encodes it again when it builds the request.

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

const whatsapp = await app({ appId: 'com.whatsapp' });
const catalogue = await developer({ devId: whatsapp.developerId });

similar

Returns apps related to a given app.

| Option | Type | Default | Description | | ------------ | --------- | -------- | ----------------------------------------------------------------- | | appId | string | required | The Google Play id of the reference app, see App ids. | | 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, see App ids. | | 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. A token outside the base64 alphabet throws ValidationError. | | score | 1 to 5 | none | Server-side star filter. Only reviews with this score are served. | | device | Device | none | Server-side device filter: device.MOBILE, device.TABLET, device.WATCH, device.CHROMEBOOK, device.TV. | | pageSize | number | see below | Maximum reviews per RPC call, 1 to 4500. |

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

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

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

Without paginate, reviews keeps requesting until it holds num reviews and always returns nextPaginationToken: null. Only a paginate: true call hands back a token to continue from.

Filters are applied by Google Play, so a filtered call downloads only the reviews it returns. Both filters can be combined and both are kept on every continuation request:

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

const angryTabletUsers = await reviews({
  appId: 'com.whatsapp',
  score: 1,
  device: device.TABLET,
  num: 50,
});

The device filter selects the form factor the review was written on. device.WATCH reaches Wear OS reviews, which can be a large share of a watch face or fitness listing; Google also files some reviews from 2011 to 2013, before Wear OS existed, under that value. Apps built for a single form factor, such as an Android TV launcher, return the same set for every device value, and reviews from a form factor without a device value appear only in the unfiltered stream, so the device sets do not add up to the unfiltered total.

When the library knows how many reviews you want, it asks Google for exactly that many, up to 4500 per request: num: 10 costs one request of ten reviews and num: 5000 costs two requests. A manual paginate: true page or an open-ended reviewsIterator requests 150 per page. pageSize overrides either default. Google serves an empty payload above 4500, so larger values are rejected as a ValidationError.

reviews with num and reviewsAll with maxReviews carry a request budget of twice the ideal request count. If Google keeps serving tokens without filling pages, the call returns what it collected and emits request-budget-exhausted through onIntegrityEvent with context reviews. A walk of full pages never exhausts the budget.

A pagination token is a position cursor: it encodes where the previous page ended, not the filters or page size that produced it, so pass the same score, device and pageSize on every page of one walk. Replaying a token under a different sort restarts from the first page of that sort.

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...'
}

score is the star rating from 1 to 5. A few legacy reviews from the early years of Google Play carry no star rating and come back with score: 0, matching the original google-play-scraper. The score filter never serves them.

permissions

Returns the permissions an app requests, one entry per permission group that lists it.

| Option | Type | Default | Description | | ------- | --------- | -------- | ----------------------------------------------------------------------------------- | | appId | string | required | The Google Play id of the app, see App ids. | | short | boolean | false | When true, return a flat string[] of common permission names, each listed once. |

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: 'read the contents of your USB storage', type: 0, group: 'Photos/Media/Files' },
  { permission: 'read the contents of your USB storage', type: 0, group: 'Storage' },
  { permission: 'take pictures and videos', type: 0, group: 'Camera' },
  { permission: 'view network connections', type: 1, group: 'Other' },
];

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

The group is the name of the permission group Google Play files the permission under, in the language of the requested lang, or an empty string when Google Play omits the name. A few legacy permissions belong to two groups (the USB storage permissions sit under both Photos/Media/Files and Storage), so the detailed list repeats such a permission once per group. To list each permission once, drop the group:

const unique = [
  ...new Set(detailed.map((entry) => (typeof entry === 'string' ? entry : entry.permission))),
];

The short list already does this for the common permissions, in the order Google Play serves them.

dataSafety

Returns the data safety section of an app.

| Option | Type | Default | Description | | ------- | -------- | -------- | ------------------------------------------------------- | | appId | string | required | The Google Play id of the app, see App ids. |

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

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

lang localizes the labels and country selects the storefront. Most reports are identical across countries, but a storefront can add regional entries (the Indian storefront adds a "UPI payments verified" security practice to payment apps). An app that Google Play withholds from a country has no report there and resolves with the empty report described under Failure behavior. An app that does not exist resolves the same way in every language.

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',
      descriptionHTML: 'Your data is transferred over a secure connection'
    }
  ],
  privacyPolicyUrl: 'https://policies.google.com/privacy'
}

Every string in the report except descriptionHTML is plain text. Google ends some practice descriptions with a link to the Play Families Policy or to the App Defense Alliance security standard; the library removes the tag and keeps its text, so the description reads ...against a global security standard. See details. descriptionHTML keeps the description as Google serves it, link included.

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 shared client whose promise-returning methods share an in-memory LRU cache, so equivalent calls made within the TTL resolve from cache. It accepts every createClient option plus the two cache bounds:

| Option | Type | Default | Description | | ------------------ | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | lang | string | 'en' | Language default applied to every call that does not set its own. | | country | string | 'us' | Country default applied to every call that does not set its own. | | throttle | number | none | One shared limiter for every request the client makes, cache misses included. | | requestOptions | object | none | Client-level HTTP overrides, merged under per-call ones. See requestOptions. | | onDegradation | function | none | Client-level degradation callback. See Monitoring drift. | | onIntegrityEvent | function | none | Client-level integrity callback. See Monitoring drift. | | maxAgeMs | number | 300000 | Time to live per cache entry, in milliseconds, counted from the moment the filling request completes. A positive integer up to 2147483647. | | max | number | 1000 | Maximum number of cached entries. A positive integer up to 1000000. |

max counts entries, not bytes. A list, search, developer or similar result keeps every item summary twice (summary and summaryHTML), and Google fills that cell with the whole description, so one entry for 100 items holds roughly 0.5 MB of text. Size max for the result shapes you cache.

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.

Entries are keyed by the options after validation and defaulting, so {} and { lang: 'en', country: 'us' }, country: 'US' and country: 'us', and the same options in any property order share one entry. lang, country, every feature option, and the headers, timeoutMs, retries, fetchImpl and signal fields of requestOptions separate entries. fetchImpl and signal are compared by identity: callers that share a signal share an entry and its cancellation, callers with distinct signals use separate entries, and a fresh signal on every call never hits, so prefer timeoutMs for per-call deadlines. throttle, concurrency and every callback are ignored by the key, so inline callbacks are fine.

The cached methods are 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 (treat it as read-only), and a rejected call is never cached. apps and the fullDetail lookups of search, list, developer and similar are served from the app entries they warm. The iterators, reviewsAll and apps are not cached themselves; they share the client's defaults, limiter and request options.

Cache hits replay their events. Degradation and integrity events recorded while an entry was fetched are delivered again, in order, to the callbacks of every call that hits it; lifecycle hooks are not, because a hit performs no request. A replayed callback that throws rejects only its own call. Events recorded before a shared request fails still reach every caller that shared it. Events from the app lookups a fullDetail call or an apps batch makes belong to that outer call: they reach its callbacks, and a cached fullDetail result replays them with its entry.

The client exposes cache controls without global state:

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

invalidate takes a method name and the options you would pass to it, applies the client defaults and validation (throwing ValidationError for options the method would reject), drops the matching entry and returns whether one existed. size counts live entries.

Streaming and bulk reads

reviews(), search() and developer() collect a fixed num of items before they resolve. To walk results lazily and stop when you have seen enough, use the async iterators. They fetch nothing until the first for await and stop fetching the moment the consumer breaks, so "first ten reviews then stop" costs one page request. Open-ended streams request 150 reviews per page unless you pass pageSize, and score and device filters are honoured on every page.

The iterators are available 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. Requests are sized to the remaining count, up to 4500 per request, so maxReviews: 2000 costs one request. A capped read carries the same request budget as reviews and reports request-budget-exhausted when it runs out. 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.

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

  • There is no listIterator. list() sends a single batchexecute RPC that returns up to num items in one response, with no server-side pagination token to iterate.
  • Iterators do not accept fullDetail or num. Streaming consumers call client.app() per item when they need full details and control the end by breaking. 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). | | device | MOBILE ('mobile'), TABLET ('tablet'), WATCH ('watch'), CHROMEBOOK ('chromebook'), TV ('tv'), the review device filter. | | 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 the library raises is a typed subclass of GooglePlayError, so you can branch on the cause instead of parsing message strings. Two rejections come from your own code instead: a cancelled call rejects with the reason you passed to abort(), and an onDegradation or onIntegrityEvent callback that throws surfaces its own error.

| Error | Extends | Thrown when | | ----------------- | ----------------- | ------------------------------------------------------------------------------------------- | | GooglePlayError | Error | Base class for every error the library throws. | | ValidationError | GooglePlayError | The options you passed fail their zod schema, including a malformed appId. | | 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. a well-formed appId that no listing uses. | | RateLimitError | HttpError | Google Play responds 429 after retries are exhausted. | | BlockedError | GooglePlayError | A consent wall redirect, or a captcha challenge redirect that retries cannot clear. | | 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

A malformed appId never reaches Google Play: it throws ValidationError on every method, except that apps reports it as a rejected entry (see App ids). An unknown but well-formed 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 AppsEntry carrying the error), 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 |

dataSafety recognizes a missing app from the structure of the error page Google Play serves, so the empty report is the same whichever lang you ask for. The same empty report comes back when the app is withheld from the requested country.

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 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, 5xx and captcha challenge redirects, 0 to 5. Default 2. | | signal | AbortSignal | Cancels 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) => 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 with jitter. A well-formed Retry-After header (seconds or an HTTP date, as RFC 9110 allows) overrides the backoff up to a fixed ceiling of 60 seconds. A longer wait is not honoured: the call ends at once with RateLimitError for a 429 or BlockedError for a captcha challenge redirect, independent of timeoutMs, which bounds a single attempt. A malformed, past or negative value is ignored.

Request lifecycle hooks

The three on* callbacks expose request telemetry at every level (direct calls, createClient and memoized), including what a 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 failed, and the next RequestEvent is that attempt plus one. ResponseEvent adds status and durationMs, measured from immediately 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 one RequestEvent and one ResponseEvent.

Hooks are telemetry only. A hook that throws or returns a rejecting promise is swallowed and the request proceeds unchanged, unlike 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:[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.

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). That 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:[email protected]:8080',
  'http://user:[email protected]: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, which prevents most blocks before rotation is needed. 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. 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.

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

With a fetchImpl that honors init.signal, as the native fetch does, cancellation is immediate at every point in the request lifecycle:

| Where the abort lands | What happens | | -------------------------------------------- | ----------------------------------------------------------------------------------------- | | Before the call starts | Rejects without contacting Google Play; no onRequest event is emitted | | During the request or the body read | Rejects with the signal's reason, not a wrapped HttpError | | During retry backoff or a Retry-After wait | Rejects before the delay expires; no further attempt is made | | While queued behind a throttle slot | Rejects 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.

Cloudflare Workers

The library runs inside Cloudflare Workers without extra configuration. It needs no nodejs_compat flag, and every compatibility date works, including dates before 2022-10-31 that still use the legacy URL parser. CI runs the published build inside workerd at three compatibility dates for every pull request.

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

const DEFAULT_APP_ID = 'com.google.android.apps.translate';

export default {
  async fetch(request: Request): Promise<Response> {
    const appId = new URL(request.url).searchParams.get('id') ?? DEFAULT_APP_ID;
    try {
      const { title, score, installs } = await app({ appId });
      return Response.json({ appId, title, score, installs });
    } catch (error) {
      if (error instanceof NotFoundError) {
        return new Response('Unknown app', { status: 404 });
      }
      throw error;
    }
  },
};
{
  "name": "play-lookup",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
}

The bundled library adds about 220 KiB to a Worker, 44 KiB gzipped. A few platform limits shape how you call it:

| Topic | What to know