DEV Community

ApiVault Labs
ApiVault Labs

Posted on

Build a Google Play Review Triage Queue with Python

Disclosure: ApiVault Labs publishes the Google Play Reviews Scraper used for collection in this guide. The Python review step below runs locally on a saved JSON export and makes no network requests. We have a commercial interest in the Actor.

A product team investigating an Android release needs a small set of review records it can inspect, reproduce and connect to follow-up work. An average star rating cannot tell that team which complaint needs a reply, whether two rows represent the same review, or whether a version value was missing from the source.

This workflow produces a queue of low-star reviews with no observed developer reply. It keeps a separate list of exclusions so a reviewer can see what the rule left out. It does not classify sentiment, diagnose a bug or measure the percentage of all users affected.

Start with a bounded collection

Open the Google Play Reviews Scraper and start with one Android package. This complete input is a configuration example, not evidence that a collection was run for this article:

{
  "mode": "reviews",
  "appInputs": ["com.spotify.music"],
  "country": "us",
  "language": "en",
  "sort": "newest",
  "ratings": [1, 2],
  "replyFilter": "without_reply",
  "maxReviews": 100,
  "maxResults": 100,
  "maxRunSeconds": 120,
  "includeAppDetails": false
}
Enter fullscreen mode Exit fullscreen mode

Set the Apify run timeout at least 60 seconds above maxRunSeconds. Review the account spending controls before starting. After the run stops, inspect SUMMARY and export the Dataset as JSON. Save it as reviews.json.

The Actor applies the star and reply filters while collecting. They can require scanning additional source pages, so 100 delivered rows is a ceiling rather than a promise. A valid zero-row result means no matching rows were delivered under the source conditions and limits. A platform status of SUCCEEDED does not establish complete coverage; partial, time and budget outcomes appear in the collection summary.

country selects a storefront. It does not identify a reviewer's country. language requests a locale; it does not translate reviews or prove the language of each text. Add date or exact version filters only when the research question calls for them. Missing source versions cannot support a version-specific conclusion.

Keep app identity with the review ID

Use the pair (appId, reviewId) as the queue key. This avoids collapsing records from different apps when exports are combined. Keep at, appVersion and reviewCreatedVersion as separate evidence fields. The script leaves missing values as null and omits public display names and avatars from the working queue.

Save this as triage_reviews.py. It accepts the standard JSON array export, keeps the first observed row for each key, and records later occurrences for review. It does not choose which copy is more current; inspect duplicate exclusions before treating combined exports as a current snapshot.

"""Review a saved Google Play Dataset JSON export; no API calls or paid runs."""
import json
from pathlib import Path
import sys


def triage(rows):
    if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
        raise ValueError("Expected a JSON array of review objects")
    seen, queue, excluded = set(), [], []
    for position, row in enumerate(rows):
        app, review = row.get("appId"), row.get("reviewId")
        reason = None
        if row.get("recordType") != "review":
            reason = "not_review"
        elif not all(isinstance(value, str) and value.strip() for value in (app, review)):
            reason = "missing_identity"
        elif (app, review) in seen:
            reason = "duplicate_review"
        else:
            seen.add((app, review))
            score, content, reply = row.get("score"), row.get("content"), row.get("replyContent")
            if type(score) is not int or not 1 <= score <= 5:
                reason = "invalid_score"
            elif not isinstance(content, str) or not content.strip():
                reason = "missing_text"
            elif reply is not None and not isinstance(reply, str):
                reason = "invalid_reply"
            elif score > 2:
                reason = "outside_star_filter"
            elif reply and reply.strip():
                reason = "observed_reply"
        if reason:
            excluded.append({"position": position, "appId": app, "reviewId": review, "reason": reason})
        else:
            fields = ("appId", "reviewId", "score", "at", "appVersion", "reviewCreatedVersion", "content")
            queue.append({field: row.get(field) for field in fields})
    return {"queue": queue, "excluded": excluded}


if __name__ == "__main__":
    rows = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8-sig"))
    print(json.dumps(triage(rows), ensure_ascii=False, indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it against the saved file with Python 3:

python triage_reviews.py reviews.json > triage.json
Enter fullscreen mode Exit fullscreen mode

The local check uses eight synthetic rows: two different apps share an illustrative review ID; the rest exercise a duplicate, an observed reply, a five-star review, a missing identifier, an app-detail row and blank text. The result is 2 queued and 6 excluded. Those are test outcomes, not collected customer feedback. Separate checks reject malformed input, boolean star values and non-text reply fields.

A low-star queue is a research sample

For each queued review, record a human decision: actionable issue, needs more context, already addressed or outside the current investigation. Link the decision to the app and review key. Reopen the source or collect a later bounded sample when current reply state matters: an absent reply in one export does not prove a developer has never responded.

Avoid reporting this queue's low-star share as an app-wide dissatisfaction rate. The input deliberately selects one- and two-star reviews without observed replies. Its average, issue counts and reply rate describe that selected sample. Keyword filters would narrow it further; repeated complaints are evidence to investigate rather than confirmed unique affected people.

Independent collection runs can return reviews seen before. A controlled continuation through CHECKPOINT.input is a separate feature; keep its filters unchanged and use one continuation at a time. This local script is a review aid for saved exports, not a persistent change monitor.

Read the price with its unit

As checked on November 15, 2026, the Actor charges $0.02 per 1,000 useful delivered rows, plus user-paid Apify platform usage, with no Actor-start event. One hundred delivered review rows therefore have $0.002 in result fees; that arithmetic does not include compute, storage or transfer. Narrow filters can increase platform cost per delivered row. The local Python step starts no Actor run and adds no Actor result fees.

Production input and field descriptions were checked against build 0.3.1. Sources: production build metadata and schema, Dataset export documentation and the Actor's current README and Pricing tab. Source coverage, completeness and execution time remain dependent on Google and the chosen limits.

Top comments (0)