# Apple App Store Scraper - App Metadata & Ratings by Country (`eiv/play-store-reviews-scraper`) Actor

Scrape Apple App Store and Google Play app metadata: ratings per country, install counts, pricing, current version and release notes. Feed it app IDs, store URLs or search terms and it profiles everything it finds across the storefronts you name. Reviews optional. From $X / 1,000 apps.

- **URL**: https://apify.com/eiv/play-store-reviews-scraper.md
- **Developed by:** [Eimantas V](https://apify.com/eiv) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.06 / 1,000 review scrapes

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Google Play Store Reviews Scraper

Pull Google Play reviews with the star rating, full text, the app version each
one was written against, the developer's reply and how long it took — and
Google's own topic classification of what the review is about.

**Reviews on Play are partitioned by language.** Asking for English and asking
for Spanish returns two entirely different sets of reviews, not the same
reviews translated. Measured on one app across six languages: **zero overlap
between any pair, 600 distinct reviews where English alone gave 100.** Every
other scraper in this category takes one language and presents it as the app's
reviews. This one takes as many as you name and unions them.

No browser. One JSON request returns 200 reviews in about 200 ms.

### What you get per review

| Field | Notes |
|---|---|
| `rating`, `text`, `textLength` | `text` is `null`, never `""`, when a review carries no words |
| `appVersion` | The release the review was written against. Present on ~98% |
| `qualityTags` | Google's own classification, e.g. `vaf_app_quality_stability`, `vaf_app_quality_ads_frequency`. Present on 35-56% — see below |
| `authorId` | Stable across apps and time — the only safe key for longitudinal work |
| `replyText`, `replyAt`, `replyLatencyHours` | The developer's answer and how many hours it took. Latency is `null` — never negative — when the review was edited after the reply |
| `reviewEditedAfterReply` | The user revised the review after support answered. True on ~11% of rows |
| `lastUpdatedAt`, `lastUpdatedAtEpoch` | When the review was last **edited** — the only timestamp Play exposes. See below |
| `language` | Which corpus this row came from |
| `thumbsUpCount`, `authorName`, `authorAvatarUrl`, `reviewId`, `appId`, `sourceUrl` | |

Plus one **coverage summary row per app**: how many reviews came back per
language, how many duplicates and filtered rows were dropped, and whether the
corpus was exhausted or the cap stopped it. A run says what it covered, not
only what it found.

`null` means Google did not report it. It never means zero and never means
empty — an absent `appVersion` recorded as `""` would let a `GROUP BY` invent a
release that never shipped.

### Play does not expose when a review was posted

Only when it was last **edited**. That distinction is not cosmetic: across 300
live Spotify reviews, **33 carried a developer reply older than the review's own
timestamp** — the user came back and revised it after support answered. Explicit
"Edit:" markers are 6.5x more common in those rows than in the rest.

So the field is called `lastUpdatedAt` rather than `publishedAt`, and
`replyLatencyHours` is `null` on those rows rather than a negative number
asserting that the developer answered before the review existed. The fact is
kept in `reviewEditedAfterReply`, which is a useful signal in its own right.

There is also no reviewer country. Play accepts a `gl` parameter and ignores
it — measured at 100/100 identical results between `gl=US` and `gl=DE` — so a
`country` field would be an assertion about the reviewer that the data does not
support.

### Why the topic tags matter

Slot 12 of every review carries Google's own auto-classification — generic
quality axes (`stability`, `performance`, `battery_efficiency`,
`ads_frequency`, `visual_appeal`, `usability`) plus per-vertical topics. It is
free, pre-labelled topic data sitting in an obscure nested slot that naive
parsers skip. It is the thing buyers otherwise pay an LLM to derive.

**Coverage depends on how old the review is**, because Google's classifier runs
behind the review. Measured on 300 Spotify reviews per ordering:

| Ordering | Median review age | Tagged |
|---|---|---|
| `newest` | 1 day | **35%** |
| `helpfulness` | 33 days | **52%** |
| — reviews under 30 days | | 46% |
| — reviews 30 days or older | | 56% |

So a `newest`-sorted pull is the *least* tagged. If the tags are what you are
after, pull with `helpfulness`, or re-pull older windows later — an untagged
review can acquire tags after Google gets to it.

### Input

```json
{
  "apps": ["com.spotify.music"],
  "languages": ["en", "es", "de", "pt", "fr", "ja"],
  "maxReviewsPerApp": 600,
  "sortBy": "newest"
}
```

`apps` takes bare package names or Play Store URLs. `languages` is the one that
matters — see above. `maxReviewsPerApp` is spent **round-robin** across the
languages you select, so six languages and a 600 cap gives you 100 from each,
not 600 English ones. Adding languages changes which reviews you get, not how
many you are charged for.

Filters — `minRating`, `maxRating`, `updatedAfter`, `onlyWithReply` — are
applied before billing. Filtered rows are never charged.

`maxReviewsPerApp` bounds the **output**; `maxReviewsScanned` (default 20,000)
bounds the **work**. With a selective filter those differ enormously — WhatsApp
has zero 1-2 star reviews carrying a developer reply in its first 3,000 — so
without the second bound a 200-review request paginates until the run times
out. When the budget runs out the summary row says so, with
`stoppedOnScanLimit: true` and the `reviewsScanned` count, because "none in the
first 20,000" is a different answer from "none exist".

### Pricing

From **$0.06 per 1,000 reviews** — $0.00012 each on the Free plan, falling to
$0.00006 on Business. Apps that cannot be resolved, duplicate reviews and
filtered rows are never charged. Full workings in
[docs/PRICING.md](docs/PRICING.md).

### Notes on the implementation

The request body's nesting is not guessable and gets this wrong silently. The
shape `[2, count, [null, null, token]]` — the one most published notes use —
returns **HTTP 200 carrying a PlayDataError and zero rows**, which is
indistinguishable from an app with no reviews. The working shape is
`[2, null, [count, null, token], null, []]`, it is written once in
[src/modules/rpc.ts](src/modules/rpc.ts), and there is a test whose only job is
to stop it drifting back.

The sort code is the second element of that block, not the first. Slot 0 is
inert — 2, 4, 5, 6 and 7 all return the byte-identical relevance-ordered page —
so a "newest" option built on it looks like it works and quietly returns Play's
own ordering forever. Measured, not assumed: `[2][1]` of `1` is relevance, `2`
is date-descending and holds across pages, `3` is rating-descending.

The cap has the same property in reverse. It counts reviews *kept*, so a
filter matching nothing means the loop never reaches it and paginates forever
— bounded output, unbounded work. That one shipped, and the run it killed came
back as TIMED-OUT.

The language round-robin has the same property. Drained one language at a time,
the first one eats the whole cap and the rest never run — you select six
languages and get 200 English reviews, with the feature you chose silently not
happening. Clamping the loop is not enough, because Play returns whatever page
size it is asked for; the request itself has to be clamped to the share. That
also has a test.

# Actor input Schema

## `apps` (type: `array`):

App IDs (`com.spotify.music`) or Play Store URLs. Both work.

## `languages` (type: `array`):

**This is the one that matters.** Google returns a *different set of reviews* for each language, not the same reviews translated - measured at zero overlap. One language gets you one slice. Six languages measured 600 distinct reviews where English alone gave 100. Each language is billed for the reviews it actually returns.

## `maxReviewsPerApp` (type: `integer`):

Summed across every language. 200 arrive per request.

## `maxReviewsScanned` (type: `integer`):

Bounds the **work**, where the cap above bounds the **output**. With a selective filter these differ enormously: WhatsApp has zero 1-2 star reviews carrying a developer reply in its first 3,000, so asking for 200 of them would otherwise paginate until the run times out. Reviews examined but filtered out are never charged.

## `sortBy` (type: `string`):

**Newest** is date-descending and the order holds across pages, so it is the one to use for incremental syncs. **Most helpful** is Play's own relevance ordering — neither date nor rating sorted.

## `minRating` (type: `integer`):

Keep reviews at or above this many stars.

## `maxRating` (type: `integer`):

Keep reviews at or below this many stars. Set both to 1 to pull only one-star complaints.

## `updatedAfter` (type: `string`):

`YYYY-MM-DD`. Play only reports when a review was last **edited**, not when it was first posted, so this filters on the edit date. Rows dropped by it are never charged.

## `onlyWithReply` (type: `boolean`):

Useful for auditing support response times - the record carries the reply and how many hours it took.

## `proxyConfig` (type: `object`):

Optional. Play answers this endpoint from datacenter IPs without complaint, so a proxy is not required.

## Actor input object example

```json
{
  "apps": [
    "com.spotify.music",
    "https://play.google.com/store/apps/details?id=com.duolingo"
  ],
  "languages": [
    "en",
    "es",
    "de",
    "pt",
    "fr",
    "ja"
  ],
  "maxReviewsPerApp": 200,
  "maxReviewsScanned": 20000,
  "sortBy": "newest",
  "minRating": 1,
  "maxRating": 5,
  "updatedAfter": "2026-01-01",
  "onlyWithReply": false,
  "proxyConfig": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per review, plus one coverage summary row per app.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "apps": [
        "com.spotify.music"
    ],
    "languages": [
        "en",
        "es",
        "de",
        "pt",
        "fr",
        "ja"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("eiv/play-store-reviews-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "apps": ["com.spotify.music"],
    "languages": [
        "en",
        "es",
        "de",
        "pt",
        "fr",
        "ja",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("eiv/play-store-reviews-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "apps": [
    "com.spotify.music"
  ],
  "languages": [
    "en",
    "es",
    "de",
    "pt",
    "fr",
    "ja"
  ]
}' |
apify call eiv/play-store-reviews-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,eiv/play-store-reviews-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/9WLGF7zr4QNBhASsz/builds/pwbaf5nHZZ9jbB8lf/openapi.json
