# Multi-Location Review Exception & Reply Audit (`plusonets/multi-location-review-exception-audit`) Actor

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.

- **URL**: https://apify.com/plusonets/multi-location-review-exception-audit.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** Business, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 branch checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Multi-Location Review Exception & Reply Audit

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 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.

```json
{
  "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:

```json
{
  "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:

```json
{
  "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`, 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.

```bash
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.

# Actor input Schema

## `reviewSource` (type: `string`):

Where the reviews come from. Sample is fictional and is what the default run uses.

## `reviewsDatasetId` (type: `string`):

Dataset from a review collection you already ran in this account. The actor only reads it. Select it here so a limited-permission run is allowed to read that dataset.

## `reviewsJson` (type: `string`):

A JSON array of review objects, or an object with an items, reviews, or data array. Used when Review source is JSON text.

## `reviewsCsv` (type: `string`):

CSV text with a header row. Used when Review source is CSV text.

## `reviewsUrl` (type: `string`):

Public http(s) URL of a JSON or CSV review file. Private addresses and localhost are rejected.

## `fieldMapping` (type: `object`):

Optional map from a canonical field to a source path. Canonical fields: placeId, placeName, address, reviewId, text, rating, reviewDate, ownerResponse, ownerResponseDate, reviewUrl, listingUrl. Example path: reply.message. When set, that path is used instead of the automatic aliases.

## `branchRegistry` (type: `string`):

Optional JSON array or CSV of branches: branchId, name, address, and placeIds (pipe-separated in CSV). A branch with no joined reviews is reported as unknown, not fine. Leave blank on the sample run.

## `rules` (type: `object`):

Serious rating, theme minimum, window in days, slow-reply threshold, disabledThemes, and customThemes. A custom theme is {id, label, phrases, excludePhrases, minCount, severity}.

## `stateStoreName` (type: `string`):

Named key-value store for this brand's baseline. Use a different name per brand. Sample runs ignore this and write review-exception-audit-sample-state instead.

## `alertOnlyOnChange` (type: `boolean`):

When on, the dataset contains only new or updated exceptions. The portfolio report still lists the full queue. Leave off to export the full queue every run.

## `maxReviews` (type: `integer`):

Cap on reviews read from the source. A hit makes coverage partial.

## `enableLlm` (type: `boolean`):

Optional. Phrase rules always run. A model is asked only about low-star reviews that matched no phrase, and only with your key. If the call fails, phrase results are kept.

## `llmApiKey` (type: `string`):

Secret key for an OpenAI-compatible chat endpoint. Stored encrypted. Leave empty to stay on phrase rules.

## `llmBaseUrl` (type: `string`):

HTTPS origin, usually https://api.openai.com/v1. Private and local addresses are rejected.

## `llmModel` (type: `string`):

Model id sent to the chat completions endpoint.

## Actor input object example

```json
{
  "reviewSource": "sample",
  "fieldMapping": {},
  "rules": {
    "seriousRatingAtOrBelow": 2,
    "themeMinCount": 2,
    "windowDays": 30,
    "unansweredMaxAgeDays": 90,
    "slowReplyAfterDays": 3,
    "disabledThemes": [],
    "customThemes": []
  },
  "stateStoreName": "review-exception-audit-state",
  "alertOnlyOnChange": false,
  "maxReviews": 5000,
  "enableLlm": false,
  "llmBaseUrl": "https://api.openai.com/v1",
  "llmModel": "gpt-4o-mini"
}
```

# Actor output Schema

## `exceptionQueue` (type: `string`):

One flat row per collection gap, unanswered serious review, slow reply, theme cluster, or unassigned review.

## `evidence` (type: `string`):

The same rows with dates, reply age, snippets, and match fields.

## `portfolioReport` (type: `string`):

Coverage, per-branch counts, and charged units for this run.

## `runSummary` (type: `string`):

Short OUTPUT record with coverage and charged units.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/multi-location-review-exception-audit").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("plusonets/multi-location-review-exception-audit").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call plusonets/multi-location-review-exception-audit --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/multi-location-review-exception-audit"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/EcN6hvpmoxPeLb7fb/builds/wchdLbT6RlODV5icQ/openapi.json
