@mradex77/google-play-scraper - v1.0.0
    Preparing search index...

    @mradex77/google-play-scraper - v1.0.0

    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.

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

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

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

    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.

    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.

    • 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

    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: 'apps-help@google.com',
    developerWebsite: 'http://support.google.com/translate',
    genre: 'Tools',
    genreId: 'TOOLS',
    categories: [{ name: 'Tools', id: 'TOOLS' }],
    icon: 'https://play-lh.googleusercontent.com/...',
    screenshots: ['https://play-lh.googleusercontent.com/...'],
    contentRating: 'Everyone',
    adSupported: false,
    updated: 1719878400000,
    version: 'Varies with device',
    comments: [],
    appId: 'com.google.android.apps.translate',
    url: 'https://play.google.com/store/apps/details?id=com.google.android.apps.translate'
    }

    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.

    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.

    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.

    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'];
    

    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.

    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.

    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.

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

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

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

    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',
    ];

    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.

    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.

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

    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.

    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);
    }
    • 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.

    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';
    

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

    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

    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.

    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.

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

    NODE_USE_ENV_PROXY=1 node app.js
    

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

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

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

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

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

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

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

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

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

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

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

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

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

    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.

    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.

    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.

    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.

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

    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.

    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.

    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.

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

    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.

    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

    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.

    MIT