Repository avatar
AI Tools
v0.1.2
active

gpscrape

io.github.kryuchenko/gpscrape

Read-only Google Play data: app details, search, charts, reviews, permissions, data safety

Documentation

google-play-scraper

Tests Coverage Go Reference

Read public Google Play Store data from the shell or from Go: app details, reviews, search, charts, permissions, data safety, per-country availability, and the store's complete list of package ids.

The gpscrape command writes newline-delimited JSON for jq and other stream readers. The Go package exposes the same operations with contexts, iterators and batched lookups; its root module imports only the standard library, and a test enforces that.

It reads the anonymous web interface of play.google.com, so it sees what a browser sees. Lists and charts stop at about 200 apps; the whole store is reachable only through the sitemaps. Read Limitations before building on either.

Requires Go 1.27. Inspired by facundoolano/google-play-scraper (Node.js).

Quick start: command line

go install github.com/kryuchenko/google-play-scraper/cmd/gpscrape@latest
$ gpscrape app com.spotify.music | jq '{title, developer, score, installs}'
{
  "title": "Spotify: Music and Podcasts",
  "developer": "Spotify AB",
  "score": 4.35,
  "installs": "1,000,000,000+"
}

Each app is one JSON record in snake_case, scores rounded to two places; -raw writes the library's own record instead, every field it parses. Paging commands write records as they arrive; Ctrl-C stops a run and keeps what it has written, and a second Ctrl-C exits at once:

$ gpscrape reviews com.spotify.music -limit 2 | jq -c '{score, untrusted_text}'
{"score":3,"untrusted_text":"I will give these apps three star because I like it a little bit"}
{"score":5,"untrusted_text":"so far so good, better than Amazon music."}

gpscrape -h lists the commands, gpscrape <command> -h the flags each one adds. Command line covers the common jobs.

Quick start: Go

go get github.com/kryuchenko/google-play-scraper
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	gps "github.com/kryuchenko/google-play-scraper"
)

func main() {
	client := gps.NewClient(gps.WithThrottle(200 * time.Millisecond))

	app, err := client.App(context.Background(), "com.spotify.music", gps.AppOptions{})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(app.Title, app.Score, app.Installs)
	// Spotify: Music and Podcasts 4.344856 1,000,000,000+
}

Every method takes a context.Context and honours cancellation. Errors that carry an HTTP status are *StatusError, so you can tell a missing app from a transport failure. Set a throttle: the client is unthrottled by default, which suits only one-off lookups. Signatures, option fields and per-method caveats are on pkg.go.dev.

What you can read

  • App details, for one app or for many in a single request.
  • Reviews: one page, or every review an app has, in every language.
  • Search, autocomplete suggestions, similar apps, and a developer's apps.
  • Charts: top free, top paid, grossing, new and movers, per category.
  • Permissions and the data-safety declaration.
  • Availability across the 177 storefront country codes.
  • Google Play Games on PC: whether a game has a Windows build, and the screenshots, trailer and rating of its PC listing.
  • The catalog: every package id the store lists, counted in ninety seconds or swept in full, kept current as a snapshot, and indexed by genre.

Command line

Commands that make requests take -lang, -country, -throttle, -concurrency and -timeout. -adaptive turns -throttle into a floor and lets the client find its own rate. Records go to stdout, one JSON object per line; progress and errors go to stderr.

app, search, list, reviews, similar, developer, permissions, datasafety and availability write the records of package records: snake_case fields, third-party text in untrusted_*. The MCP server returns the same objects, so one jq filter works on both. -raw writes the library's own types with every field instead, -fields app_id,score keeps only the keys you name, and gpscrape schema <command> lists a command's flags and fields as JSON.

Errors and exit codes

When stderr is not a terminal, a failed run writes {"error":{"code","message","retryable"}} to it (GPSCRAPE_ERRORS=json|text overrides); the codes are the MCP server's, and the exit status follows from the code. catalog sweep and an unlimited catalog ids need -yes when no terminal is attached.

CodeExitMeaningRetryable
invalid_argument2fix the argumentsno
confirmation_required2an expensive run needs -yesno
not_found3no such app or page in this storefrontno
not_listed3the store returned nothing for this id hereyes
rate_limited4the store is refusing requests; waityes
timeout5the store did not answer within -timeoutyes
cancelled130interrupted; printed as gpscrape: interrupted, no error objectno
store_error1any other failure talking to the storeyes
internal1a bug in gpscrape (a panic); please report itno
busy-gpscrape-mcp only: the server is at capacityyes

A run that succeeds exits 0.

Running inside Claude Code's sandbox on macOS

The sandbox blocks the system trust service, so Go cannot verify TLS and every request fails with x509: OSStatus -26276; the error carries a hint with the fix. Set "sandbox": {"enableWeakerNetworkIsolation": true} (with play.google.com an allowed domain), or add "gpscrape *" to sandbox.excludedCommands.

One app, or many

gpscrape app com.spotify.music | jq .score
gpscrape app com.spotify.music com.whatsapp com.duolingo   # all three in one request
gpscrape permissions com.spotify.music com.whatsapp        # likewise
gpscrape datasafety com.spotify.music
gpscrape availability com.spotify.music -countries us,de,jp

Several ids ride in one batchexecute request, so a list of apps costs one throttle interval, not one per app. In a batch, an id that fails is still a line of output in its position, {"app_id":"…","code":"not_found","error":"…"} (-fields keeps code and error on it), so stdout holds exactly one record per id and a pipeline never needs stderr to line them up. A single id that fails writes nothing to stdout; the error and the exit code say why. Ids are checked before any request: a name where a package id belongs is invalid_argument.

availability lists the countries under available, region_locked and not_listed, counts the conclusive answers in checked, and gives the reason for any failed probe under errors. -countries takes Play Store country codes only (there is no store in cn, for example). A run in which every country failed exits with their error code, not 0. With -raw the record is the library's: a status per country under statuses, and globallyRemoved.

Google Play Games on PC

gpscrape pcapp com.mytona.cookingdiary.android | jq '{onPC, title, youTubeId, screenshots}'
gpscrape pcapp com.mytona.cookingdiary.android com.whatsapp -lang de

pcapp reads the PC store's listing, one page per app. A game with a PC build returns onPC: true and the PC listing's own media: screenshots (a PC set or a subset of the phone ones), the trailer for that language as video and youTubeId, the PC players' rating, and the system requirements. An app without a PC build returns onPC: false and nothing else, because Google redirects it to the phone listing.

onPC reflects the public PC listing only. A game the Windows storefront promotes before release (Dispatch, in November 2026) reads onPC: false, and availability shows it region-locked everywhere.

-country does not tell you where the PC client runs: the PC store also serves listings to storefronts the client does not support, Russia and Iran among them. For the official region list use PCRegions() in Go, a static copy of Google's help page.

Reviews, including all of them

gpscrape reviews com.spotify.music -limit 500 | jq -r .untrusted_text
gpscrape reviews com.spotify.music -sort rating -score 1 -limit 100
gpscrape reviews com.spotify.music -langs en,ru,de -limit 500   # three corpora
gpscrape reviews com.spotify.music -langs all -limit 500        # every measured corpus

Language partitions reviews rather than filtering them: each hl code serves its own corpus, and the corpora do not overlap, so reading all of an app's reviews means reading every language. With -langs, -limit applies per language and each record names its corpus. There is no country filter because a review has no country: ru/kz and ru/us return the same reviews, id for id.

Search and charts

gpscrape search "podcast player" -num 50
gpscrape suggest spoti
gpscrape list -collection new_free GAME_PUZZLE      # what is new in a category
gpscrape list -collection top_free -full=false      # chart rows only, no extra requests
gpscrape categories -kind game                      # the genre ids list accepts
gpscrape similar com.spotify.music
gpscrape developer "Spotify AB"

search, list, similar and developer fetch each result's details in batches of 32 by default, because the result rows lack the developer id, the price (search, similar, developer) and the tagline (charts). -full=false skips that; rows without a price then carry no free or price field rather than a guess (with -raw, "priceKnown": false).

Each of these returns one page, about 200 apps at most; Google rejects the continuation token for stateless clients. For more apps, combine sources (categories, collections, locales, the similar graph).

The catalog

catalog is a group of verbs. The right-hand column is what each costs in requests to Google:

VerbWhat it doesRequests
checkhas Google republished the catalog?2
newapps the store lists as recently published17
sizehow many apps there are, give or take 1%~900
genresgenre changes and removals, from a snapshot~3.3k, plus ~310 per -confirm-gone storefront
sweepthe complete id list, and the exact count~88k
appsthe id list by genre, from what is on disknone
diffcompare two snapshots already on disknone
idsstream ids without keeping any stateas many as you ask for
$ gpscrape catalog check -dir ./catalog
{"generation":"2026-08-23_1787500934","built":"2026-08-23T16:02:14Z","age_hours":287,"shards":83445,"up_to_date":false}
$ gpscrape catalog size | jq -c '{apps, half_width, shards_read, shards_total, met_target}'
{"apps":3549110,"half_width":35366,"shards_read":866,"shards_total":83445,"met_target":true}

That is 3,549,110 apps give or take 35,366 at 95% confidence, from 866 of 83,445 shards, in about ninety seconds, on 2026-08-23. The store grows: on 2026-11-07 it had 87,679 shards and, by a 0.7% sample, about four million apps. The record also carries the dispersion check behind the interval; the exact count costs a full sweep.

A full sweep is one request per shard, about 88,000 on 2026-11-07, some five hours at the default throttle. Keep a snapshot directory and let the tool decide when a sweep is due: Google republishes the sitemaps every few days, and shard names carry the generation that produced them.

gpscrape catalog sweep -dir ./catalog            # sweeps only if a new generation exists
gpscrape catalog sweep -dir ./catalog -keep 3    # and keeps the three newest snapshots
gpscrape catalog diff -dir ./catalog -changes    # one record per id added or removed

Shards are immutable within a generation, so an interrupted sweep resumes from the shards it has not reached. A completed sweep writes a sorted snapshot, a manifest with the generation id and a checksum, and a delta against the previous snapshot, and prints the manifest as its one output record. If the previous snapshot cannot be read, the manifest says why in delta_error.

catalog sweep and catalog genres default to -adaptive, because over hours of requests a fixed interval turns one long Retry-After into a failed shard. The adaptive client slows down and waits instead, and stops after three refused probes. -adaptive=false restores the fixed interval. -adaptive never goes faster than -throttle unless -adaptive-min allows it: -adaptive-min 40ms lets it probe for a faster rate, slowly. Each refusal is remembered for 30 minutes, and the rate settles at 80% of the median refused rate instead of hitting it again. Probing means meeting Google's limit, so it is opt-in.

After a sweep, run catalog size -dir ./catalog to check the snapshot against an independent estimate (snapshot_ids, snapshot_agrees). It is the only check that notices a shard that parsed short without an error, which would leave the snapshot with fewer ids than the store has.

A snapshot directory takes one writer at a time. sweep and genres hold sweep.lock while they run and refuse a second run against the same directory; a lock left by a dead process is cleared on the next run. check, new, size, apps, diff and ids take no lock; new only appends to its own signal log.

The genre table turns a snapshot into an app list by category. Build it once, then read it offline:

gpscrape catalog genres -dir ./catalog                        # ~3.3k requests, plus ~310 per -confirm-gone storefront
gpscrape catalog apps -dir ./catalog -genre 'GAME_*' -ids-only > games.txt
gpscrape catalog apps -dir ./catalog -genre GAME_PUZZLE

genres asks a thousand ids per request. Before calling an app gone it re-checks it in the storefronts -confirm-gone names, because an app can be listed in Brazil and invisible from the United States. A lookup that fails in that confirm pass is reported as an error, not a removal, and the row is kept. -prune drops rows for ids the snapshot no longer lists.

An interrupted genres run over the snapshot resumes. It checkpoints every 10,000 ids into genres.tsv.merge and genres.tsv.resume.json; a rerun on the same snapshot, table, -lang and -country continues after the last checkpoint, or goes straight to the confirm pass if the walk had finished. Any other rerun, and every -ids run, starts clean. Records printed before the interruption are not printed again, and the summary counts the whole pass. After a hard kill (not Ctrl-C), records between the last checkpoint and the kill may be missing from stdout or appear twice; the table is unaffected.

Without a snapshot, catalog ids streams ids straight from the sitemaps:

gpscrape catalog ids -shards 0-99 -ids-only > ids.txt

Shards are read in parallel and ids come out as each shard arrives, so the order differs between runs; sort the output if you compare it, or sweep for a sorted snapshot.

Debugging a run

gpscrape reviews com.spotify.music -limit 50 -debug              # every request, to stderr
GPSCRAPE_DEBUG=1 gpscrape catalog check                          # the same, for a script
gpscrape catalog sweep -dir ./catalog -debug -log-file sweep.log -trace sweep.trace

-debug logs each request, response, retry and throttle change, with the connection timings behind each request; the first line names the build and the run's settings. Results stay on stdout, so a pipeline is unaffected:

level=DEBUG msg=request method=GET url="https://play.google.com/store/apps/details?id=com.spotify.music&hl=en&gl=us" attempt=1 waited=0s
level=DEBUG-4 msg=connection dns=24.077208ms connect=19.079541ms tls=54.277042ms reused=false addr=216.58.204.174:443 ttfb=242.611416ms
level=DEBUG msg=response method=GET url="https://play.google.com/store/apps/details?id=com.spotify.music&hl=en&gl=us" attempt=1 status=200 took=242.692542ms bytes=1254486

Bodies and headers are never logged. -trace keeps a Go execution trace of the run's last minutes in memory and writes it at exit, Ctrl-C included; go tool trace sweep.trace then shows where the time went, throttle versus Google.

The transport is Go's default, so HTTPS_PROXY=http://127.0.0.1:8080 routes every request through a proxy such as mitmproxy, and GODEBUG=http2debug=1 prints the HTTP/2 frames.

Go package

MethodReturns
App / AppsManyfull details for one app / for many, batched
Searchsearch results, optionally price-filtered and detail-enriched
Reviews / ReviewsSeqone page of reviews / an iterator that keeps paginating
Developera developer's apps, by name or numeric id
Similarapps Google lists as similar
Permissions / PermissionsManyrequested permissions with their group label
DataSafetythe data-safety declaration and privacy policy URL
Suggest / SuggestManysearch autocomplete terms
Lista store collection for a category: top free/paid/grossing, newest, movers
ClusterURLs / Clusterthe clusters on a category page, and one cluster's apps
Categoriesthe 54 store categories (a static list; no request)
Availability / AvailableCountriesper-country status across the 177 codes in AllCountries
PCAppthe Google Play Games on PC listing: whether there is one, its media, rating and requirements
PCRegionsthe 141 regions where Google Play Games on PC is offered (a static snapshot; no request)
CategoryAppsmany slices of one category, unioned and deduplicated
CatalogSeq / CatalogSizeevery app id in the store via the sitemaps / the estimated count
CatalogShardSeqthe same sweep with the shard as its unit, so a run can resume from the URLs it did not reach
DigestsSeqa few fields (genre, and whether the app is listed) for very many apps, a thousand per request
SitemapGeneration / Generationwhich build of the catalog Google is serving, in two requests, with its build time and ordering
ReviewLanguagesthe 71 language codes that each serve their own review corpus (a static list; no request)
CurrentThrottlethe interval between requests the client is using now, which is what the adaptive controller moves

Most option structs take Lang (ISO 639-1) and Country (ISO 3166-1 alpha-2), defaulting to en/us.

Client options: WithThrottle, WithConcurrency, WithTimeout, WithUserAgent, WithHTTPClient, WithRetry, WithAdaptiveThrottle, WithHooks. Every request takes one path: cache seam, retry with full jitter, throttle, HTTP.

Batched lookups

AppsMany, PermissionsMany and SuggestMany pack up to 32 lookups into one request and send the packs over WithConcurrency workers. The throttle meters requests, not lookups: 32 apps took 9.76s as 32 page fetches and 146ms as one batched request, at about 20KB per app instead of a megabyte of markup.

for _, r := range client.AppsMany(ctx, appIDs, gps.AppOptions{}) {
	if r.Err != nil {
		log.Printf("%s: %v", r.AppID, r.Err)
		continue
	}
	fmt.Println(r.App.Title, r.App.Score)
}

Results are positional (out[i] describes appIDs[i] whatever order Google answers in), and each carries its own error, so a failed chunk does not cost the rest of the run.

Iterators

ReviewsSeq and CatalogSeq are iter.Seq2 values, so a caller can stop at any point instead of waiting for a sweep to finish:

for pkg, err := range client.CatalogSeq(ctx, opts) {
	if err != nil {
		return err
	}
	if seen(pkg) {
		break
	}
}

ReviewsAll and EnumerateCatalog are deprecated as of 1.4.0 and will be removed in v2.

Logging and tracing

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: gps.LevelTrace}))
client := gps.NewClient(gps.WithThrottle(200*time.Millisecond), gps.WithLogger(logger))

The client is silent by default and logs at Debug at most: requests, responses, retries, throttle waits and adaptive interval changes. LevelTrace adds the connection timings behind each request. WithHooks gives callbacks for a metrics registry, WithHTTPClient accepts an instrumented transport such as otelhttp.NewTransport, and every operation that makes a request opens a runtime/trace task, so go tool trace attributes each request to its call.

Limitations

  • Lists and charts cap at ~200 apps per category and collection. Google rejects the continuation token behind List and Search for stateless clients; the Node and Python libraries hit the same server-side wall. CategoryApps combines sources instead (one GAME_ACTION run reached ~1,800 unique apps), but it cannot reach an app that never charts, never ranks in search and appears in no similarity graph.
  • The whole store is reachable only through the sitemaps, and they carry package ids and nothing else: no titles, no ratings. Pair a sweep with AppsMany to resolve the ids you care about.
  • Rate limits differ by endpoint. The sitemap CDN sustained 32 requests per second for 90 seconds with no failures. The details endpoint tolerated a short burst but not sustained load, and an availability sweep across 177 countries lost several to fetch errors. Be conservative, or use WithAdaptiveThrottle and let the client find the rate.
  • Payload shapes change without notice. A field that moves yields a zero value rather than failing a crawl. A canary suite (build tag canary) runs against the live store to catch drift.
  • No authenticated API. Real pagination and bulk detail lookups exist only on Google Play's mobile protobuf API, which needs a Google account token, a device check-in and maintained reverse-engineered schemas. This library does not implement it; see AuroraOSS/gplayapi.

Submodules

  • apidoc/: an auto-generated OpenAPI 3.1 description of the private, undocumented Google Play endpoints this library calls.
  • lightfeed/: FeedBrowser support for Cluster. It drives Lightpanda over CDP to scroll a category page past the depth a stateless client reaches. Cluster returns ErrFeedPaginatorRequired unless you inject the paginator. The middle step, FeedMode: FeedLightweight, lives in the root module: it replays the page's own stateless feed tokens, one extra request per topic, and takes GAME_ACTION from ~18 apps to ~77; the browser reaches ~149.
  • mcp/: gpscrape-mcp, an MCP server that gives AI agents (Claude Code, Codex CLI, OpenCode, ChatGPT and other MCP clients) the store's quick lookups as tools, over stdio or Streamable HTTP.

All three are separate Go modules; the root library stays stdlib-only.

skills/gpscrape/ is a skill for Claude Code and other agents that read SKILL.md: when to use the MCP tools and when the CLI, and how to filter the CLI's output. Copy the folder to ~/.claude/skills/.

Documentation

  • API reference
  • CHANGELOG.md: what each release changed, and what was measured and rejected along the way
  • Runnable programs in examples/: app-availability, catalog-crawler, category-coverage, verify-game-genre, yandex-taxi-reviews
  • CONTRIBUTING.md for layout, tests and the release process
  • SECURITY.md for reporting a vulnerability

Development

GOWORK=off go test -short -race ./...                  # the library, offline; what CI runs
go test -tags canary -run TestCanary -v -timeout 15m ./...   # live contract tests, slow

License

MIT

MCPB
https://github.com/kryuchenko/google-play-scraper/releases/download/mcp-v0.1.2/gpscrape-mcp.mcpb
Install Command
# mcpb: https://github.com/kryuchenko/google-play-scraper/releases/download/mcp-v0.1.2/gpscrape-mcp.mcpb
OCI
ghcr.io/kryuchenko/gpscrape-mcp:0.1.2
Install Command
docker pull ghcr.io/kryuchenko/gpscrape-mcp:0.1.2:undefined