Multi-Location Review Exception & Reply Audit avatar

Multi-Location Review Exception & Reply Audit

Pricing

from $3.00 / 1,000 branch checkeds

Go to Apify Store
Multi-Location Review Exception & Reply Audit

Multi-Location Review Exception & Reply Audit

Join reviews you already collected to your branches, then queue operational themes, unanswered serious reviews, and collection gaps. Does not scrape Google, Yelp, or Trustpilot.

Pricing

from $3.00 / 1,000 branch checkeds

Rating

0.0

(0)

Developer

Olalekan Johnson

Olalekan Johnson

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

12 days ago

Last modified

Security

Limited permissions

Share

Operations teams with several locations already collect reviews. This Actor does not collect them again. It reads a dataset, JSON file, or CSV you already have, joins each review to a branch, and returns the exceptions worth a human's time: a location with no data, a serious review nobody answered, a slow reply, or the same operational complaint showing up more than once.

Unofficial, not affiliated with Google, Yelp, or Trustpilot. It does not open those sites, and it does not draft replies.

The default input is a built-in fictional sample for Harbor & Pine Dental. Pressing Run with no changes returns an exception queue in under a minute and does not use the network.

Who it is for

Multi-location operators, franchise coordinators, and agencies who already run a review export and need a repeatable queue:

  • which branches were missing from this extract
  • which 1- and 2-star reviews still have no owner response, and how old that silence is when the timestamps are real
  • which serious reviews were answered, but only after several days
  • which operational themes clustered inside the window (closed during posted hours, wait time, rude staff, billing, cleanliness, booking problems, plus your own phrases)

A branch that produced no reviews is unknown, not fine. The queue says so explicitly.

What you put in

FieldDefaultPurpose
Review sourceBuilt-in samplesample, an Apify dataset in this account, JSON text, CSV text, or a public http(s) URL
Reviews datasetemptyPicked with read-only access. The run does not need full account permissions.
Field mappingautomaticOverride one canonical field (placeId, placeName, address, reviewId, text, rating, reviewDate, ownerResponse, ownerResponseDate, reviewUrl, listingUrl) with a dotted path such as reply.message. An override replaces the automatic aliases for that field.
Branch registrysample registryJSON or CSV with branchId, name, address, and placeIds (pipe-separated in CSV).
Business rulesserious ≤ 2, theme count 2, 30-day window, slow reply after 3 daysdisabledThemes and customThemes (id, label, phrases, excludePhrases, minCount, severity).
State store namereview-exception-audit-stateNamed key-value store for this brand. Sample runs ignore it and write review-exception-audit-sample-state, so the green button cannot replace a production baseline.
Alert only on changeoffDataset then contains only new or updated exceptions. The portfolio report still has the full queue.
Maximum reviews5000 (max 20000)A cap makes coverage partial.
ModeloffOptional. Your own API key, HTTPS OpenAI-compatible chat endpoint. Phrase rules always run first.

Automatic aliases cover common Maps-style exports (placeId, stars, publishedAtDate, responseFromOwnerText, nested reviews), Yelp-style exports (businessId, comment, rating, ownerReply), and Trustpilot-style exports (businessUnitId, dates.publishedDate, a reply object). Nested review lists are expanded. The Actor does not guess a branch when two registry rows claim the same place id.

What you get back

The default dataset is a flat, CSV-friendly exception queue. Lists are semicolon-separated strings. Each row has an evidence hash, an observed-at time, a snippet when the row is about a review, a coverage status, and a match method.

The default key-value store holds:

  • OUTPUT — short run summary: coverage, counts, charged units, whether state was committed
  • PORTFOLIO-REPORT — the same counts plus one summary per branch

The named state store (not the default store) holds the baseline used on the next run: BASELINE, BASELINE-PREVIOUS, LAST-EXCEPTIONS, and a per-run charge ledger. A failed read, a blocked dataset, or an unsafe URL does not replace BASELINE.

Default sample, first run

Seventeen rows for four registry branches plus one review that matches none of them:

  • Cedar Park is a collection gap. Coverage is unknown. The summary says unknown, not fine.
  • Austin North clusters closed during posted hours and cleanliness (two reviews each) and queues four unanswered serious reviews. The single wait-time review does not meet the minimum of two, so there is no wait-time cluster. "Couldn't wait to come back" is praise and is not a wait-time hit.
  • Austin South clusters billing (three reviews), queues one unanswered 2-star review, and flags a 1-star review answered after 5 days (replyAgeStatus computed). A 4-star review with a reply and no reply timestamp is not queued; reply age stays unknown.
  • Round Rock clusters booking problems and rude staff from the two reviews with absolute dates. The review dated "3 days ago" is still an unanswered serious row, with unansweredAgeStatus unknown, and it is not counted toward the cluster minimum.
  • Pflugerville is not in the registry. Its review is unassigned-review plus an unanswered serious row. It is not merged into a nearby branch.

Portfolio coverage is partial because Cedar Park had no data and one review was unassigned.

{
"recordType": "exception",
"exceptionKey": "hp-cedar-park|collection-gap",
"exceptionType": "collection-gap",
"severity": "high",
"changeStatus": "new",
"branchId": "hp-cedar-park",
"branchName": "Harbor & Pine Dental — Cedar Park",
"branchAddress": "1400 Cypress Creek Road, Cedar Park, Texas 78613",
"placeId": "ChIJsampleCedarPark01",
"inRegistry": true,
"coverageStatus": "unknown",
"confidence": "high",
"matchMethod": "registry",
"title": "No reviews supplied for this branch",
"summary": "Harbor & Pine Dental — Cedar Park had no joined reviews in this run. Coverage is unknown, not fine. Do not treat this location as problem-free.",
"reviewCount": 0,
"evidenceHash": "be4fd31994f9930d",
"observedAt": "2026-10-23T15:00:00Z",
"sourceLabel": "sample",
"llmUsed": false,
"isSample": true,
"sampleNotice": "Built-in fictional sample for Harbor & Pine Dental. Not real patients, locations, or reviews."
}

A slow reply from the same sample:

{
"exceptionType": "slow-reply",
"severity": "medium",
"branchId": "hp-austin-south",
"reviewId": "sample-as-bill-2",
"rating": 1,
"ownerResponded": true,
"replyAgeDays": 5,
"replyAgeStatus": "computed",
"unansweredAgeStatus": "replied",
"theme": "billing",
"coverageStatus": "complete",
"isSample": true
}

OUTPUT.counts on that first sample run:

{
"recordsRead": 9,
"reviewsDeduped": 14,
"branchesChecked": 5,
"collectionGaps": 1,
"unansweredSerious": 9,
"themeClusters": 5,
"exceptionsTotal": 17,
"coverageStatus": "partial"
}

On a machine that is not pay-per-event, chargedUnits stay at 0 and deliveredUnits still show 5 branch checks and 14 reviews analyzed. The rows are written either way.

How a review becomes an exception

Join order is exact and unique: place id, then normalized full address, then a looser address (zip and unit dropped, must contain a street number), then a unique normalized name. Nothing fuzzy. Ambiguous place ids stay unassigned.

A review is new, edited, or unchanged against the named-store baseline. Material means the normalized text, the rating, or the owner-response text changed. Case and whitespace alone are not an edit. Changing only the response date is not an edit.

Reply age is filled in only when both the review time and the response time are absolute and plausible (ISO, a named month, a unix timestamp, or an unambiguous day/month). "3 days ago", "yesterday", and 01/02/2026 stay unknown. A response timestamp more than a day before the review also stays unknown. Serious unanswered reviews are queued when the age is unknown or when it is within unansweredMaxAgeDays (default 90, and never shorter than the theme window).

Theme clusters count only reviews with a reliable date inside the window. Mentions with no reliable date are reported on the row and are not counted toward the minimum.

Coverage on a branch:

StatusMeaning
unknownRegistry branch, no joined reviews. Not fine.
emptyThe extract had this place and an explicit empty review list.
unsupportedRecords were present but none had text or a rating.
partialTruncated extract, charge limit, unassigned reviews, or analysis stopped early.
completeJoined reviews were analyzed.
blockedThe dataset or URL could not be read. The baseline is left alone.

Pricing

Pay-per-event prices:

EventPriceCharged when
branch-checked$0.003A branch was actually checked and its rows were delivered, including the unassigned bucket when it has reviews.
review-analyzed$0.005A review is new or materially edited, and that review's exceptions were delivered.
apify-actor-start$0.005Once per run, charged by the platform.

There is no per-dataset-item charge. This Actor charges only the two events above, and only after the unit is delivered. Unchanged reviews are not charged again. A charge limit stops the run cleanly: branches not reached are absent, a branch that was checked but not analyzed is marked deferred rather than "fine", and fingerprints already charged are not billed again. The previous good baseline is kept; the partial baseline only adds reviews that were delivered.

Memory for a normal run is 512 MB. Platform compute is billed separately by Apify. The figures below are event charges only.

RunEventsEvent cost
Pilot: 20 branches and 400 new reviews, one run20 × $0.003 + 400 × $0.005 + $0.005 start$2.065
Research workload: 10,000 checks and 10,000 analyses10,000 × $0.003 + 10,000 × $0.005, plus $0.005 per run$80.00 plus $0.005 per run
Quiet next day: 200 branches, no new or edited reviews200 × $0.003 + $0.005 start$0.605

Scheduled use and integrations

Schedule the task after the scraper that fills the dataset. Point reviewsDatasetId at that dataset and keep stateStoreName stable per brand.

  • Webhook. On run succeeded, read the default dataset items. Rows are flat, so a webhook consumer can filter on severity, exceptionType, changeStatus, and branchId without unpacking nested JSON.
  • n8n or Make. Apify "run finished" trigger, then HTTP GET the dataset items (or the PORTFOLIO-REPORT key). Map branchName, title, summary, reviewUrl, and severity into a Slack message, email, or ticket. Turn on alertOnlyOnChange when the channel should only hear about new or edited exceptions.
  • Google Sheets. Append dataset items as rows. exampleReviewIds and rulesFired are already single cells.

isSample is true on every sample row, so a downstream filter can drop the demo before it reaches a live channel.

Run it locally

Python 3.12.

pip install -r requirements.txt
python3 -m pytest
APIFY_LOCAL_STORAGE_DIR=./storage python3 main.py

The dataset is written under storage/datasets/default/. OUTPUT and PORTFOLIO-REPORT are in the default key-value store. The sample baseline is in the store named review-exception-audit-sample-state. Local runs are not pay-per-event, so charged counts are 0 and the queue is still written.

To audit your own file, pass input JSON (an INPUT file in the local key-value store, or the Apify input UI) with reviewSource set to json, csv, url, or dataset.

Build and push with the Apify CLI from this directory when you are ready. This repository does not publish the Actor.

Limits

  • Phrase rules are English. Other languages need customThemes phrases or the optional model.
  • The model, if enabled, sees at most 15 low-star reviews that matched no phrase. A failed or non-JSON response keeps the phrase result. The base URL must be public HTTPS.
  • No reply drafting, no contact enrichment, and no reviewer outreach. Reviewer names are used only to tell two reviews apart when the export has no review id, and they are not written to the dataset.
  • A fingerprint (place + reviewer + date token + the start of the text) is weaker than a source review id. Prefer exports that include one.
  • Address normalization is best-effort. Street suffixes and US state names are expanded. The token ne is treated as northeast, so a Nebraska abbreviation in the middle of an address may not match the spelled-out state.
  • An exclusion phrase drops the whole theme. "Very clean" will suppress cleanliness even when the same sentence also complains.
  • The baseline grows with every distinct review. This version does not prune it.
  • Re-running a resurrected dataset can append duplicate rows to a new dataset. It does not charge the same review fingerprint twice.
  • Alert-only mode hides a persistent gap until its evidence changes. The full queue remains in PORTFOLIO-REPORT. That suppression is updated only after a run that was not stopped by the charge limit.
  • If a run is killed after a charge is accepted and before the ledger is saved, that one review can be billed again on a later run id. The same run id will not bill it twice.

FAQ

Does this scrape Google Maps, Yelp, or Trustpilot? No. Unofficial, not affiliated with those brands. You supply reviews you already collected.

Why is a silent branch high severity? No data is not a clean score. The row is a collection gap with coverage unknown.

Why is reply age blank? The export used a relative time, an ambiguous date, or a response with no timestamp. The row says unknown instead of inventing a number.

Will the second scheduled run cost the same as the first? Branch checks are charged every run. Review analysis is charged only for new or materially edited reviews.

Can I use my own complaint phrases? Yes. Add a customThemes entry, or add phrases to a built-in theme id to extend it. Invalid theme ids are skipped and listed on the portfolio report.

Contact

Questions about this Actor: plusonecares@gmail.com.