Multi-Location Review Exception & Reply Audit
Pricing
from $3.00 / 1,000 branch checkeds
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
12 days ago
Last modified
Categories
Security
Limited permissionsShare
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
| Field | Default | Purpose |
|---|---|---|
| Review source | Built-in sample | sample, an Apify dataset in this account, JSON text, CSV text, or a public http(s) URL |
| Reviews dataset | empty | Picked with read-only access. The run does not need full account permissions. |
| Field mapping | automatic | Override 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 registry | sample registry | JSON or CSV with branchId, name, address, and placeIds (pipe-separated in CSV). |
| Business rules | serious ≤ 2, theme count 2, 30-day window, slow reply after 3 days | disabledThemes and customThemes (id, label, phrases, excludePhrases, minCount, severity). |
| State store name | review-exception-audit-state | Named 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 change | off | Dataset then contains only new or updated exceptions. The portfolio report still has the full queue. |
| Maximum reviews | 5000 (max 20000) | A cap makes coverage partial. |
| Model | off | Optional. 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 committedPORTFOLIO-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 (
replyAgeStatuscomputed). 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
unansweredAgeStatusunknown, and it is not counted toward the cluster minimum. - Pflugerville is not in the registry. Its review is
unassigned-reviewplus 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:
| Status | Meaning |
|---|---|
unknown | Registry branch, no joined reviews. Not fine. |
empty | The extract had this place and an explicit empty review list. |
unsupported | Records were present but none had text or a rating. |
partial | Truncated extract, charge limit, unassigned reviews, or analysis stopped early. |
complete | Joined reviews were analyzed. |
blocked | The dataset or URL could not be read. The baseline is left alone. |
Pricing
Pay-per-event prices:
| Event | Price | Charged when |
|---|---|---|
branch-checked | $0.003 | A branch was actually checked and its rows were delivered, including the unassigned bucket when it has reviews. |
review-analyzed | $0.005 | A review is new or materially edited, and that review's exceptions were delivered. |
apify-actor-start | $0.005 | Once 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.
| Run | Events | Event cost |
|---|---|---|
| Pilot: 20 branches and 400 new reviews, one run | 20 × $0.003 + 400 × $0.005 + $0.005 start | $2.065 |
| Research workload: 10,000 checks and 10,000 analyses | 10,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 reviews | 200 × $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, andbranchIdwithout unpacking nested JSON. - n8n or Make. Apify "run finished" trigger, then HTTP GET the dataset items (or the
PORTFOLIO-REPORTkey). MapbranchName,title,summary,reviewUrl, andseverityinto a Slack message, email, or ticket. Turn onalertOnlyOnChangewhen the channel should only hear about new or edited exceptions. - Google Sheets. Append dataset items as rows.
exampleReviewIdsandrulesFiredare 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.txtpython3 -m pytestAPIFY_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
customThemesphrases 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
neis 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.