Google Play Store Scraper avatar

Google Play Store Scraper

Pricing

Pay per usage

Go to Apify Store
Google Play Store Scraper

Google Play Store Scraper

Fetches Google Play app metadata — ratings, installs, pricing, developer info, and version — with optional version gating.

Pricing

Pay per usage

Rating

0.0

(0)

Developer

Milad Amirzadeh

Milad Amirzadeh

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

6 days ago

Last modified

Share

Google Play Scraper

Fetches app metadata from Google Play for a list of app URLs. Optionally gates output on the current store version being strictly newer than a supplied minimum version — useful for polling apps and only processing them when a new release appears. Sibling to the Apple App Store scraper in this workspace; same conventions, same minVersion gating idea, adapted to Google Play's data shape.

A pre-configured Apify task with example input is ready to run as-is: Google Play — App Details.

Input

{
"apps": [
{
"url": "https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm&hl=en&gl=US",
"minVersion": "6.0"
},
{
"url": "https://play.google.com/store/apps/details?id=org.telegram.messenger&hl=en&gl=US"
}
],
"maxRetries429": 3,
"allowApproximateVersion": false
}
  • apps (array, required): list of apps to scrape
    • url (string, required): a Google Play app URL, or a bare package name (e.g. org.telegram.messenger). Accepts locale/query params (hl, gl) embedded in the URL.
    • minVersion (string, optional): if provided, only emit full data when the current store version is strictly greater than this value. Omit to always fetch full data.
    • hl / gl (string, optional): override the URL's language/country for this item specifically.
  • proxyConfiguration (object, optional): Apify proxy configuration.
  • maxRetries429 (boolean or number, optional, default 3): retry strategy for HTTP 429 responses, same semantics as the App Store actor — false/0 disables retrying, true uses the default of 3, or supply a custom count. Every attempt draws a fresh proxy URL with no sticky session, so each retry after a 429 goes out over a different IP, combined with exponential backoff (2s, 4s, 8s, ... capped at 30s, plus jitter).
  • allowApproximateVersion (boolean, optional, default false): see "The version field" below.

Output

One dataset record per input item, in input order. Non-skipped records include the full field set; skipped records include skipped: true and a reason; failed items include skipped: true and an error.

Example — full record (paid app, single canonical version):

{
"url": "https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm&hl=en&gl=US",
"appId": "net.dinglisch.android.taskerm",
"skipped": false,
"hl": "en",
"gl": "US",
"title": "Tasker",
"description": "...",
"summary": "Automate everything from settings to photos, SMS to speech. ADC2 prize winner.",
"developer": "joaomgcd",
"developerId": "8102570190170276456",
"developerUrl": "/store/apps/dev?id=8102570190170276456",
"developerWebsite": "https://tasker.joaoapps.com/dl",
"developerEmail": "support@joaoapps.com",
"developerLegalName": "KITXOO",
"developerAddress": "Rua Elias Garcia\nn17 4A\n2700-310 Amadora\nPortugal",
"developerPhone": "+351 969 390 591",
"privacyPolicyUrl": "https://tasker.joaoapps.com/privacy.html",
"genre": "Tools",
"genreId": "TOOLS",
"categories": [{ "name": "Tools", "id": "TOOLS" }],
"icon": "https://play-lh.googleusercontent.com/...",
"headerImage": "https://play-lh.googleusercontent.com/...",
"screenshots": ["https://play-lh.googleusercontent.com/..."],
"contentRating": "Everyone",
"contentRatingDescription": "Content is generally suitable for all ages. May contain minimal cartoon, fantasy or mild violence and/or infrequent use of mild language.",
"released": "Jul 3, 2010",
"releasedTimestamp": 1278173814,
"updated": "Feb 24, 2026",
"updatedTimestamp": 1771934588,
"installs": "1,000,000+",
"minInstalls": 1000000,
"realInstalls": 2592744,
"score": 4.022774,
"scoreText": "4.0",
"ratings": 56074,
"reviews": 6884,
"histogram": { "1": 9518, "2": 2553, "3": 2553, "4": 3946, "5": 37497 },
"free": false,
"priceText": "$4.49",
"priceCurrency": "USD",
"priceMicros": 4490000,
"offersIAP": false,
"inAppPurchasePriceRange": null,
"permissions": ["Device & app history", "Calendar", "Contacts", "Location", "..."],
"version": "6.6.20",
"versionSource": "play-store-page",
"versionApproximate": false,
"scrapedAt": "2026-09-19T21:46:25.421632+00:00"
}

Example — skipped record (current version not newer than minVersion):

{
"url": "https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm&hl=en&gl=US",
"appId": "net.dinglisch.android.taskerm",
"skipped": true,
"reason": "current version not newer than minVersion",
"version": "6.6.20",
"versionSource": "play-store-page",
"minVersion": "99.0",
"scrapedAt": "2026-09-19T21:46:25.421632+00:00"
}

Example — error record (bad package id, delisted app, or unavailable in the requested country):

{
"url": "https://play.google.com/store/apps/details?id=com.rovio.angrybirds&hl=en&gl=US",
"appId": "com.rovio.angrybirds",
"skipped": true,
"error": "app 'com.rovio.angrybirds' not found or unavailable in gl=US (HTTP 404)",
"scrapedAt": "2026-09-19T21:46:25.421632+00:00"
}

The version field

Google Play does not expose a single "current version" the way the App Store does for every app. For the large majority of apps it's present in the very first page load (title, developer, etc. come from the same place, no extra request) — but apps published with per-device APK splits (Telegram, and many other very large apps) show "Varies with device" in the UI because there genuinely is no single canonical version on Google's side.

  • version: the resolved version string, or null when Google has none to give.
  • versionSource: "play-store-page" (the common, free case), "reviews-heuristic" (see below), or null.
  • versionApproximate: true only when versionSource is "reviews-heuristic".

When version is null and a minVersion was supplied, the item is never skipped — it's always fetched in full, per the standing rule that a missing/incomparable version should default to "treat as newer" rather than silently skip an app the caller might actually need.

If allowApproximateVersion is enabled, apps with no canonical version fall back to the highest version number seen among that app's own embedded user reviews (already present in the same page fetch — no extra request). This is real user data (what some reviewer had installed when they wrote their review), not Google's own "current version", so it can lag behind the true latest release — it's off by default for that reason.

See .recon/FINDINGS.md for the full reconnaissance writeup, including why a batchexecute RPC replay (the original plan) turned out to be unnecessary for the common case and unhelpful for the "Varies with device" case.

Why Python + curl_cffi

Following this workspace's App Store actor for consistency, rather than because Google Play strictly requires it: Google Play's page served fine to a plain curl with a normal Chrome user agent during testing, with no TLS-level fingerprinting observed. curl_cffi's Chrome impersonation is cheap insurance here, not a hard requirement the way it was for Apple's undocumented storefront API.

What's fragile

Google Play's data is embedded in the page as AF_initDataCallback({key: 'ds:N', ...}) blocks — positional arrays with no field names, unlike Apple's documented-shape JSON. If this actor starts returning unexpected nulls or wrong values, check first, in order:

  1. src/ds_blobs.py's _KEY_RE and block-parsing — the AF_initDataCallback({key: '...' syntax itself (spacing around key:/data:/sideChannel:).
  2. The ds:5 app-detail slot indices in src/extract.py — Google reshuffles these between rollouts. ds_blobs.assert_shape() raises loudly on a gross structural change (fewer than 146 slots); individual field drift is quieter and shows up as unexpected Nones. Re-run python tools/refresh_fixtures.py and re-verify indices against the fresh HTML.
  3. Whether ds:5 is still the app-detail blob at all — find_detail_blob() locates it by shape (matching the requested package at index [77][0]), so a bare key renumbering shouldn't break anything on its own.
  4. The IMPERSONATE constant in src/play_api.py — keep it a recent supported Chrome target.
  5. Fields whose position isn't fixed at all, notably contentRatingDescription (Google inserts a variable number of descriptor slots ahead of it depending on the app) — handled by a bounded scan (_content_rating_description) rather than a hardcoded index, but that scan's range may need widening someday.

Run .venv/bin/python -m pytest tests/ -v after any index change — test_no_fallback_paths_used_on_real_fixtures in particular will fail loudly if link_url/image_url had to fall back to a deep search on the real fixtures, which is the drift alarm for those two families of fields.