# Naver Place Multi-Location Review Monitor (`magenta_courser/multi-location-review-monitor`) Actor

Enter Naver Place locations (ID + your label) and get change rows - new reviews, owner replies added or edited - plus one free summary row per location: unanswered reviews, oldest unanswered age, reply rate, rating, top keywords. For franchises and agencies. No reviewer personal data.

- **URL**: https://apify.com/magenta\_courser/multi-location-review-monitor.md
- **Developed by:** [SUNGHWAN CHO](https://apify.com/magenta_courser) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 location checks

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

## Naver Place Multi-Location Review Monitor

Enter a **list of Naver Place locations** (place ID + your own label and group) and get, on every run, **what changed since the last run** — new reviews, owner replies added, owner replies edited — plus **one status row per location**: how many reviews are still unanswered, how old the oldest unanswered review is, reply rate, average rating and top keywords. Built for franchise head offices and agencies that manage many stores on Naver Map (Naver Place), South Korea's main map and local review service. **Reviewer identity is never collected.**

No login, no API key and no browser needed.

### Which Actor do I need?

| | **Multi-Location Review Monitor** (this Actor) | [Naver Place Reviews Scraper](https://apify.com/magenta_courser/naver-place-reviews-scraper) |
|---|---|---|
| Purpose | A recurring **status report across many locations**: what changed and what is still unanswered | A **raw review export**: every review of a place as a row |
| Typical user | Franchise HQ, multi-store brand, marketing or operations agency | Analyst, researcher, data pipeline, LLM input |
| Input | Locations with your `label` and `group` tags | Place IDs or URLs |
| Rows you get | Only changes (`newReview`, `ownerReplyAdded`, `ownerReplyChanged`) + one summary row per location | All reviews up to your limit (photos, visit context, reactions, verification type) |
| Owner replies | Detects replies added or edited after the review was first seen | Reply text as it is at scrape time |
| Unanswered backlog | Count, oldest age in days and reply rate per location | Not calculated |
| Scheduled run with nothing new | Location summary rows with the current unanswered status | Place summary rows only |

Use this Actor for a daily or weekly check of your stores. Use the Reviews Scraper when you need the full review history or fields such as photos. Need place IDs first? [Naver Place Search Scraper](https://apify.com/magenta_courser/naver-place-search-scraper) returns them in its `id` field.

### What you get

Every row has a `type` field.

**Change rows** (`"type": "change"`) — one per detected change, with the full review:

- `eventType`: `newReview` (a review not seen in earlier runs), `ownerReplyAdded` (a known review got its first owner reply) or `ownerReplyChanged` (the reply text was edited)
- Your `label` and `group`, `placeId`, `placeName`
- `rating`, review `text`, `visitedAt`, `createdAt` (when the review was written), keyword tags in Korean and as English codes, `menuItem`
- `hasOwnerReply`, `ownerReply`, `ownerReplyDateLabel`
- `needsAttention`: `true` when the star rating is 2 or lower, or the text contains one of these Korean complaint terms: 불친절, 불만, 실망, 불결, 불쾌, 최악, 환불, 위생…문제. A simple, fixed rule — not an AI judgment
- E-mail addresses and phone numbers written inside the review or the reply are replaced with `[email]` / `[phone]`

**Location summary rows** (`"type": "locationSummary"`, free) — one per location on every run, also when nothing changed:

- `status` and `stopReason`, so a blocked or failed location is never mistaken for "no new reviews"
- `newReviewCount`, `ownerReplyAddedCount`, `ownerReplyChangedCount` since `previousCheckedAt`, and `avgRatingOfNewReviews`
- `unansweredCountInSample`, `oldestUnansweredDaysInSample` (+ the review ID and visit date), `replyRateInSample`, `needsAttentionUnansweredCountInSample`
- `reviewsInSample`, `avgRatingInSample`, `lowRatingCountInSample`, `topKeywordsInSample` (top 5 keyword tags)
- `placeAvgRating`, `placeTotalReviewCount` (Naver's headline numbers for the whole place)

Fields ending in `InSample` are calculated over the reviews read in this run — reviews **visited within the observation window** (`windowDays`, default 30), up to `maxReviewsPerLocation`. They are not statistics of the place's whole history.

**One run summary row** (`"type": "runSummary"`, free): locations by status, total change rows, total unanswered reviews, requests sent and retried. The same report with a per-location list is saved in the run's key-value store as `RUN_SUMMARY`.

Missing values are `null`; keys are never left out. Numbers are numbers.

**Not collected:** reviewer nicknames, profile pictures, profile links, user IDs and receipt links are never requested from Naver.

### Use cases

- **Agency reporting** — one scheduled run for all client stores; group rows by `group` (client) and `label` (store) for the weekly report
- **Reply follow-up** — list locations where `unansweredCountInSample` is above 0 or `oldestUnansweredDaysInSample` is over your target, and check that replies were actually posted (`ownerReplyAdded`)
- **Reply audit** — `ownerReplyChanged` shows when a store edited a reply after you first saw it
- **Alerts** — send `change` rows with `needsAttention: true` to Slack, e-mail or a sheet through Apify integrations
- **AI agents** — a short list in, flat rows out; `RUN_SUMMARY` gives the result without reading the log

### Input

| Field | Description |
|---|---|
| `locations` | **Required.** List of objects: `placeId` (Naver Place ID or Naver Map place URL), optional `label` and `group` (your own text, copied to every row). A plain ID or URL string also works. |
| `windowDays` | Observation window in days, by visit date. Default 30. Changes are detected and summary numbers are calculated for reviews inside this window. |
| `maxReviewsPerLocation` | Upper limit of reviews read per location inside the window. Default 200, maximum 500. |
| `emitExistingOnFirstRun` | Default `false`. The first run for a location only sets the starting point (summary row, no change rows). Set `true` to also receive the reviews currently in the window as `newReview` rows with `isBaseline: true`. |
| `historyStoreName` | Key-value store that remembers what was already reported. Default `multi-location-review-monitor-history`. Use one name per client or schedule. |
| `proxyConfiguration` | Keep the default Apify Proxy. |

```json
{
  "locations": [
    { "placeId": "1922651675", "label": "Seongsu store", "group": "Client A" },
    { "placeId": "https://map.naver.com/p/entry/place/1559904682", "label": "Seoul Forest store", "group": "Client A" }
  ],
  "windowDays": 30
}
```

### Output

A change row from a test run:

```json
{
  "type": "change",
  "eventType": "newReview",
  "source": "naver",
  "schemaVersion": 1,
  "placeId": "1922651675",
  "label": "Demo store 1",
  "group": "Demo client",
  "placeName": "노틀던",
  "reviewId": "6abf33448f9dd532461b9928",
  "rating": 5,
  "text": "디저트도 예쁘고 맛있어요!! 다른메뉴도 먹으러 또오고싶어요",
  "visitedAt": "2026-10-02T04:23:12.000Z",
  "createdAt": "2026-10-02T04:29:56.000Z",
  "visitedLabel": "10.2.금",
  "createdLabel": "10.2.금",
  "keywords": ["커피가 맛있어요", "디저트가 맛있어요", "아늑해요", "음악이 좋아요"],
  "keywordCodes": ["coffee_good", "dessert_good", "cozy", "music_good"],
  "menuItem": "노틀던 누아",
  "hasOwnerReply": false,
  "ownerReply": null,
  "ownerReplyDateLabel": null,
  "needsAttention": false,
  "isBaseline": false,
  "previousCheckedAt": "2026-09-30T00:00:00.000Z",
  "url": "https://map.naver.com/p/entry/place/1922651675?placePath=/review",
  "scrapedAt": "2026-10-02T06:45:24.139Z"
}
```

A location summary row from a first run with `"windowDays": 14`:

```json
{
  "type": "locationSummary",
  "source": "naver",
  "schemaVersion": 1,
  "placeId": "1922651675",
  "label": "Demo store 1",
  "group": "Demo client",
  "placeName": "노틀던",
  "status": "completed",
  "stopReason": "windowCovered",
  "errorMessage": null,
  "isFirstCheck": true,
  "previousCheckedAt": null,
  "windowDays": 14,
  "windowStart": "2026-09-18T06:45:03.688Z",
  "windowFullyScanned": true,
  "newReviewCount": null,
  "ownerReplyAddedCount": null,
  "ownerReplyChangedCount": null,
  "changeRowsSaved": 0,
  "avgRatingOfNewReviews": null,
  "reviewsInSample": 34,
  "ratedReviewsInSample": 34,
  "avgRatingInSample": 5,
  "lowRatingCountInSample": 0,
  "repliedCountInSample": 0,
  "unansweredCountInSample": 34,
  "replyRateInSample": 0,
  "oldestUnansweredDaysInSample": 13,
  "oldestUnansweredReviewId": "6aacf2418c8d40a9626925ac",
  "oldestUnansweredVisitedAt": "2026-09-18T08:05:40.000Z",
  "needsAttentionUnansweredCountInSample": 0,
  "topKeywordsInSample": [
    { "code": "dessert_good", "keyword": "디저트가 맛있어요", "count": 31 },
    { "code": "special_menu", "keyword": "특별한 메뉴가 있어요", "count": 20 },
    { "code": "coffee_good", "keyword": "커피가 맛있어요", "count": 19 },
    { "code": "drink_good", "keyword": "음료가 맛있어요", "count": 12 },
    { "code": "kind", "keyword": "친절해요", "count": 10 }
  ],
  "placeAvgRating": 4.97,
  "placeTotalReviewCount": 1160,
  "pagesFetched": 1,
  "locationCheckCharged": false,
  "input": null,
  "url": "https://map.naver.com/p/entry/place/1922651675?placePath=/review",
  "scrapedAt": "2026-10-02T06:45:04.152Z"
}
```

Export the dataset as JSON, CSV or Excel, or read it through the Apify API. Filter by `type` to separate changes from summaries.

### How change detection works

1. **First run for a location** reads the observation window and stores a fingerprint of each review's owner reply in the key-value store `historyStoreName` in your Apify account. It returns the summary row only (`isFirstCheck: true`, change counts `null`), unless `emitExistingOnFirstRun` is on.
2. **Later runs** read the window again and compare:
   - a review that is not in history → `newReview`. Naver lists reviews by visit date, and people often write days after the visit; such late reviews are found as long as the visit is inside the window. A review that is not in history but was written before the previous check (it was outside that run's scan) is recorded silently and not reported as new.
   - a known review that now has a reply and had none → `ownerReplyAdded`
   - a known review whose reply text differs (ignoring whitespace) → `ownerReplyChanged`
3. Only change rows that were **actually stored** are recorded in history. Rows cut off by your max cost per run, or lost to an error, are reported again on the next run.

#### Status values

| `status` | Meaning |
|---|---|
| `completed` | The window was read and all changes were stored. `stopReason` is `windowCovered`, `endOfList` or `maxReviewsReached` (window larger than `maxReviewsPerLocation`; see `windowFullyScanned`). |
| `partial` | A later page failed after retries. Changes found so far are reported; the location check is not charged. |
| `budgetLimited` | Your max cost per run was reached at this location. |
| `notStarted` | Not requested and not charged (`costLimitReached` or `runTimeoutApproaching`). |
| `failed` | `blocked` (Naver kept answering HTTP 429/403), `requestFailed`, `placeNotFound`, `invalidInput` or `datasetWriteFailed`. Counts are `null`, not 0. Not charged. |

If every location fails, the run itself fails. Otherwise the run succeeds and the rows tell you what to re-check.

### What is charged

Pay per event (prices are on the Pricing tab):

- **`location-check`** — once per location whose observation window was read successfully. Charged also when nothing changed, because the summary row (unanswered count, reply rate, …) is produced on every run. Not charged for `failed`, `partial`, `notStarted` and `budgetLimited`-before-check locations.
- **`change`** — once per change row stored in the dataset.

`locationSummary` and `runSummary` rows are free. If the run reaches your **max cost per run**, the Actor stops cleanly: the number of change rows stored equals the number charged and the number reported in `changeRowsSaved`; remaining locations are `notStarted` and not requested. `locationCheckCharged` on each summary row and `locationChecksCharged` on the run summary show the checks charged.

Example: 50 locations checked daily with 40 changes a day = 50 location checks + 40 change events per run.

### Limits

- **Changes outside the window are not seen.** A reply added to a review visited 45 days ago is not reported with `windowDays: 30`. Use a longer window if stores answer late; reading more reviews does not add change charges.
- **Review text edits and deleted reviews are not reported.** A removed owner reply is not reported as an event; the summary row always shows the current unanswered count.
- **Newest visit first only**, as listed by Naver. `InSample` numbers describe the window, not the place's lifetime.
- **Hospitals and clinics:** Naver hides star ratings for medical businesses, so `rating`, `avgRatingInSample` and `placeAvgRating` are `null` there.
- **`createdAt`** comes from the timestamp inside Naver's review ID and is returned only when its Korea-time date matches Naver's own "written" label; otherwise `null`.
- **Owner reply dates have no year** on Naver (e.g. `9.30.수`), so they are kept as the original label.
- **Masking is pattern based** (e-mail addresses, Korean mobile and landline numbers). Review text is written by users and may still contain personal details they chose to share; handle it accordingly.
- **One run at a time per history name.** Two runs sharing a `historyStoreName` at the same moment can report the same change twice.
- Timestamps are UTC (`Z`); Naver's labels are Korea time (KST, UTC+9).
- The Actor only reads public pages. It does not log in and does not post replies.

### Implementation notes

- Requests go through Apify Proxy with a **new proxy session for every attempt**, up to 6 attempts with increasing waits. Naver rate-limits repeated requests from one IP (HTTP 429). The first private version of this Actor (0.1) used a shared no-proxy, no-retry request layer and was blocked with HTTP 429 on Apify; version 0.2 replaces that layer, for this Actor only, with the one already used by Naver Place Reviews Scraper.
- Up to 5 locations are read in parallel; charging, storing and history updates happen one location at a time, in input order. The time of the last completed check of all locations is kept in one `checkpoints` record, so a location with no changes needs no storage write of its own.
- Tests: `npm test` runs the offline regression suite (429 retries, cost limit, reply change detection, masking, history).

# Actor input Schema

## `locations` (type: `array`):

List of locations to monitor. Each item is an object: {"placeId": "1922651675", "label": "Seongsu store", "group": "Client A"}. placeId is the Naver Place ID (digits) or a Naver Map place URL such as https://map.naver.com/p/entry/place/1922651675; the "id" field from Naver Place Search Scraper works directly. label and group are optional free text (your store name, client, region, brand) and are copied to every output row so you can filter and group reports. A plain ID or URL string is also accepted. Duplicate places are checked once.

## `windowDays` (type: `integer`):

How many days back, by VISIT date, each run reads for every location. Whole number from 1 to 365. Default 30. Changes (new reviews, owner replies added or edited) are detected only for reviews inside this window, and the summary numbers (unanswered count, reply rate, average rating, top keywords) are calculated over this window - they are a sample, not the whole history of the place. Example: 14 for a fast daily check, 90 to keep tracking late replies.

## `maxReviewsPerLocation` (type: `integer`):

Upper limit of reviews read inside the observation window for one location, newest visit first. Whole number from 1 to 500. Default 200. If a busy location has more reviews in the window, the summary row shows stopReason "maxReviewsReached" and windowFullyScanned false - raise this value or shorten the window. Reading reviews is not charged per review.

## `emitExistingOnFirstRun` (type: `boolean`):

true or false. Default false. The first run for a location only records a starting point: it returns the summary row but no change rows, so you are not charged for reviews that already existed. Set to true to also get every review currently inside the window as a change row (eventType "newReview", isBaseline true) on that first run - useful to load the current backlog of unanswered reviews. These rows are charged like other change rows.

## `historyStoreName` (type: `string`):

Name of the key-value store in your Apify account that remembers which reviews and owner replies were already reported. Default multi-location-review-monitor-history. Use a different name for each independent schedule or client, e.g. reviews-client-a. Letters, digits and "-" only. Do not run two runs with the same history name at the same time.

## `proxyConfiguration` (type: `object`):

Proxy settings. Keep the default Apify Proxy (datacenter). Naver rate-limits repeated requests from one IP (HTTP 429); the Actor uses a new proxy session for every request attempt.

## Actor input object example

```json
{
  "locations": [
    {
      "placeId": "1922651675",
      "label": "Demo store 1",
      "group": "Demo client"
    },
    {
      "placeId": "1559904682",
      "label": "Demo store 2",
      "group": "Demo client"
    }
  ],
  "windowDays": 30,
  "maxReviewsPerLocation": 200,
  "emitExistingOnFirstRun": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `results` (type: `string`):

Dataset rows: change rows (new reviews, owner replies added or edited), one summary row per location and one run summary row. Filter by the "type" field.

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

Run status with a per-location list (status, stopReason, change counts, unanswered count, whether the location check was charged). Not billed.

# 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 = {
    "locations": [
        {
            "placeId": "1922651675",
            "label": "Demo store 1",
            "group": "Demo client"
        },
        {
            "placeId": "1559904682",
            "label": "Demo store 2",
            "group": "Demo client"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("magenta_courser/multi-location-review-monitor").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 = {
    "locations": [
        {
            "placeId": "1922651675",
            "label": "Demo store 1",
            "group": "Demo client",
        },
        {
            "placeId": "1559904682",
            "label": "Demo store 2",
            "group": "Demo client",
        },
    ],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("magenta_courser/multi-location-review-monitor").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 '{
  "locations": [
    {
      "placeId": "1922651675",
      "label": "Demo store 1",
      "group": "Demo client"
    },
    {
      "placeId": "1559904682",
      "label": "Demo store 2",
      "group": "Demo client"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call magenta_courser/multi-location-review-monitor --silent --output-dataset

```

## MCP server setup

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

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/ju0yFS0gx6jtfNE8l/builds/9BT59AvYefTQQ95B4/openapi.json
