# Maps Business Coverage Gap Finder (`practical_ai_lab_jp/pal-maps-business-coverage-gaps`) Actor

Identify category-versus-area gaps in owned place datasets, with sample limitations made explicit. For Location intelligence and market research teams. Check the live Pricing tab and input limits.

- **URL**: https://apify.com/practical_ai_lab_jp/pal-maps-business-coverage-gaps.md
- **Developed by:** [Practical AI Lab](https://apify.com/practical_ai_lab_jp) (community)
- **Categories:** Lead generation, SEO tools
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 underserved cell results

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

## Maps Business Coverage Gap Finder

Compare business listing data you already have across postal areas and categories, and get a ranked list of **category-area combinations that look underserved** relative to a transparent statistical baseline.

This Actor is an analysis tool. It **does not scrape** Google Maps or any other website, makes no network requests to third-party sites, and does not read or output phone numbers, email addresses, personal names or any other contact data.

### Who it is for

- Local-market analysts and consultants who already hold listing exports and want a quick first-pass screen of where categories look thin.
- Franchise, retail-site and service-planning teams who compare several existing datasets (for example two exports collected at different times or by different tools).
- Data teams who want a reproducible, explainable gap metric rather than a black-box score.

### What makes it different from a plain listing export

- It works on **your existing data** (inline JSON or Apify datasets in your account), so you control sourcing and compliance.
- It ranks **area x category cells**, not individual businesses.
- Two baselines: category-mix independence (default) or per-capita using a population or demand proxy you supply. With population supplied, areas with **zero** listings are also detected.
- Multi-dataset comparison: results show per-source counts for every flagged cell, and the summary shows how many distinct places appear in each source and in each combination of sources.
- Each row carries an expected count, deficit, z-score and a conservative confidence label.

### Input

| Field | Meaning |
|---|---|
| `listings` | Inline records. Needs a category (`category`, `categoryName`, `categories`, `type`) and a postal code (`postalCode`, `zip`, `postcode`, ...) or `city` when `regionMode` is `city`. Optional: `placeId`/`id` or `name` for de-duplication, `rating`, `reviewsCount`, `source` (a label for comparing datasets). |
| `datasetIds` | Up to 20 existing Apify dataset IDs holding such records. Labelled `dataset_1`, `dataset_2`, ... unless a record has its own `source`. |
| `regionMode`, `postalPrefixLength` | How areas are defined (default: first 3 postal characters). |
| `regionPopulation` | Optional `{"area": number}` demand proxy; switches to the per-capita baseline. |
| `categoryAliases` | Optional map to merge category names. |
| `minCategoryListings`, `minExpectedListings`, `maxObservedToExpectedRatio`, `minDeficit` | Flagging thresholds. |
| `maxListings`, `maxRegions`, `maxCategories`, `maxResults` | Safety and size limits. |

#### Sample input (copy and run)

The tiny sample is only for checking the output shape; the thresholds are deliberately lowered so it returns a result.

```json
{
    "listings": [
        {"placeId": "a1", "category": "Cafe", "postalCode": "100-0001", "rating": 4.4},
        {"placeId": "a2", "category": "Cafe", "postalCode": "100-0002", "rating": 4.1},
        {"placeId": "a3", "category": "Cafe", "postalCode": "100-0003", "rating": 4.6},
        {"placeId": "a4", "category": "Gym", "postalCode": "100-0004", "rating": 4.2},
        {"placeId": "a5", "category": "Gym", "postalCode": "100-0005", "rating": 3.9},
        {"placeId": "b1", "category": "Cafe", "postalCode": "200-0001", "rating": 4.0},
        {"placeId": "b2", "category": "Cafe", "postalCode": "200-0002", "rating": 4.3},
        {"placeId": "b3", "category": "Cafe", "postalCode": "200-0003", "rating": 4.5},
        {"placeId": "b4", "category": "Gym", "postalCode": "200-0004", "rating": 4.0},
        {"placeId": "b5", "category": "Gym", "postalCode": "200-0005", "rating": 4.4},
        {"placeId": "c1", "category": "Cafe", "postalCode": "300-0001", "rating": 4.2},
        {"placeId": "c2", "category": "Cafe", "postalCode": "300-0002", "rating": 4.0},
        {"placeId": "c3", "category": "Cafe", "postalCode": "300-0003", "rating": 4.7}
    ],
    "postalPrefixLength": 3,
    "minCategoryListings": 1,
    "minExpectedListings": 0.5,
    "minDeficit": 0.5
}
```

By the arithmetic of the method, area `300` has no gyms while the category mix of the other areas implies roughly 0.9 expected, so it should appear as one low-confidence row. Real analyses need far more data (see limitations).

### Output

One dataset row per flagged cell, ranked by deficit (expected minus observed):

`rank`, `region`, `category`, `observedListings`, `expectedListings`, `deficit`, `observedToExpectedRatio`, `zScore`, `confidence` (`low`/`medium`/`high`), `regionListings`, `categoryListings`, `regionPopulation`, `averageRating`, `totalReviews`, `countsBySource`, `model`.

The key-value store record `SUMMARY` holds run totals: records read, accepted, duplicates, invalid records by reason, listings outside the analysed areas, source overlap, warnings and limitations.

### Method

1. Records are normalised (case-folded category, postal prefix) and de-duplicated by place ID, or by name + area + category.
2. Counts are aggregated per area and category.
3. Expected count for a cell: `area_total * category_total / grand_total` (independence baseline), or `category_total * area_population / total_population` (per-capita baseline).
4. A cell is flagged when expected >= `minExpectedListings`, deficit >= `minDeficit` and observed <= expected x `maxObservedToExpectedRatio`.
5. `zScore = (observed - expected) / sqrt(expected)` is a rough Poisson-style measure. Confidence is `high` only with expected >= 20 and z <= -3, `medium` with expected >= 8 and z <= -2, otherwise `low`.

### Limitations and sample-size caveats

- A gap means "fewer listings than the baseline predicts in **your data**". It is not proof of unmet demand, profitability or an open market. Source data may be incomplete, stale or biased.
- The independence baseline assumes every area has the same category mix. Business districts, residential areas and rural areas differ, so false gaps and missed gaps are expected.
- The per-capita baseline is only as good as the population or demand proxy you provide. Residents are not the same as visitors, workers or buyers.
- Small samples are unreliable. With fewer than about 200 usable listings, or fewer than 5 areas, treat results as exploratory. Cells with expected counts below about 8 are labelled low confidence.
- Postal prefixes of different length or country systems are not comparable to each other; areas of very different size are compared as if equal.
- Ratings and review counts are descriptive only and are not used for flagging.
- Records with no place ID and no name cannot be de-duplicated and may be double counted.
- Input is limited to 100,000 inline records and 500,000 accepted records per run; areas and categories are capped by `maxRegions` / `maxCategories` (largest first).
- No benchmark results, accuracy figures or revenue claims are provided with this Actor.

### Data and compliance

- You are responsible for having the right to use the listing data you supply.
- Only aggregate area-category results are output. Business names are used in memory for de-duplication only and are not written to the dataset.
- Contact fields in your records are ignored.

### Pricing

The code defines the pay-per-event name `underserved-cell-result` (one event per returned result row). Events are charged only after results are stored; failed runs are not charged. No pricing plan is configured in this repository.

### Local checks

```
python -m unittest discover -s tests
```

# Actor input Schema

## `listings` (type: `array`):

Business listing records. Each needs a category and a postal code (or city when regionMode is 'city'). Optional: placeId or name (for de-duplication), rating, reviewsCount, source (label for comparing datasets). Contact fields are ignored.

## `datasetIds` (type: `array`):

Optional IDs of Apify datasets in your account that already contain business listings. Each dataset is labelled dataset_1, dataset_2, ... in the comparison. Maximum 20.

## `regionMode` (type: `string`):

Group listings by the first characters of the postal code, or by city name.

## `postalPrefixLength` (type: `integer`):

Number of leading postal code characters that define an area (letters and digits only).

## `regionPopulation` (type: `object`):

Optional map of area key to population or another demand proxy, for example {"100": 52000}. Enables a per-capita baseline and also detects areas with zero listings.

## `categoryAliases` (type: `object`):

Optional map that merges similar category names, for example {"coffee shop": "cafe"}.

## `minCategoryListings` (type: `integer`):

Categories with fewer listings in total are not analysed.

## `minExpectedListings` (type: `number`):

Ignore cells where the baseline expects fewer listings than this. Higher values reduce noise.

## `maxObservedToExpectedRatio` (type: `number`):

A cell is flagged only if observed listings are at or below this share of the expected count.

## `minDeficit` (type: `number`):

Minimum number of missing listings for a cell to be flagged.

## `maxListings` (type: `integer`):

Safety limit on accepted listings.

## `maxRegions` (type: `integer`):

Keep only the largest areas (by listings, or by population if supplied).

## `maxCategories` (type: `integer`):

Keep only the most frequent categories.

## `maxResults` (type: `integer`):

Number of top-ranked underserved cells to return.

## Actor input object example

```json
{
  "listings": [
    {
      "placeId": "a1",
      "category": "Cafe",
      "postalCode": "100-0001",
      "rating": 4.4
    },
    {
      "placeId": "a2",
      "category": "Cafe",
      "postalCode": "100-0002",
      "rating": 4.1
    },
    {
      "placeId": "a3",
      "category": "Cafe",
      "postalCode": "100-0003",
      "rating": 4.6
    },
    {
      "placeId": "a4",
      "category": "Gym",
      "postalCode": "100-0004",
      "rating": 4.2
    },
    {
      "placeId": "a5",
      "category": "Gym",
      "postalCode": "100-0005",
      "rating": 3.9
    },
    {
      "placeId": "b1",
      "category": "Cafe",
      "postalCode": "200-0001",
      "rating": 4
    },
    {
      "placeId": "b2",
      "category": "Cafe",
      "postalCode": "200-0002",
      "rating": 4.3
    },
    {
      "placeId": "b3",
      "category": "Cafe",
      "postalCode": "200-0003",
      "rating": 4.5
    },
    {
      "placeId": "b4",
      "category": "Gym",
      "postalCode": "200-0004",
      "rating": 4
    },
    {
      "placeId": "b5",
      "category": "Gym",
      "postalCode": "200-0005",
      "rating": 4.4
    },
    {
      "placeId": "c1",
      "category": "Cafe",
      "postalCode": "300-0001",
      "rating": 4.2
    },
    {
      "placeId": "c2",
      "category": "Cafe",
      "postalCode": "300-0002",
      "rating": 4
    },
    {
      "placeId": "c3",
      "category": "Cafe",
      "postalCode": "300-0003",
      "rating": 4.7
    }
  ],
  "regionMode": "postal_prefix",
  "postalPrefixLength": 3,
  "minCategoryListings": 1,
  "minExpectedListings": 0.5,
  "maxObservedToExpectedRatio": 0.5,
  "minDeficit": 0.5,
  "maxListings": 50000,
  "maxRegions": 500,
  "maxCategories": 200,
  "maxResults": 200
}
```

# Actor output Schema

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

Download rows from the latest run in JSON/CSV.

# 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 = {
    "listings": [
        {
            "placeId": "a1",
            "category": "Cafe",
            "postalCode": "100-0001",
            "rating": 4.4
        },
        {
            "placeId": "a2",
            "category": "Cafe",
            "postalCode": "100-0002",
            "rating": 4.1
        },
        {
            "placeId": "a3",
            "category": "Cafe",
            "postalCode": "100-0003",
            "rating": 4.6
        },
        {
            "placeId": "a4",
            "category": "Gym",
            "postalCode": "100-0004",
            "rating": 4.2
        },
        {
            "placeId": "a5",
            "category": "Gym",
            "postalCode": "100-0005",
            "rating": 3.9
        },
        {
            "placeId": "b1",
            "category": "Cafe",
            "postalCode": "200-0001",
            "rating": 4
        },
        {
            "placeId": "b2",
            "category": "Cafe",
            "postalCode": "200-0002",
            "rating": 4.3
        },
        {
            "placeId": "b3",
            "category": "Cafe",
            "postalCode": "200-0003",
            "rating": 4.5
        },
        {
            "placeId": "b4",
            "category": "Gym",
            "postalCode": "200-0004",
            "rating": 4
        },
        {
            "placeId": "b5",
            "category": "Gym",
            "postalCode": "200-0005",
            "rating": 4.4
        },
        {
            "placeId": "c1",
            "category": "Cafe",
            "postalCode": "300-0001",
            "rating": 4.2
        },
        {
            "placeId": "c2",
            "category": "Cafe",
            "postalCode": "300-0002",
            "rating": 4
        },
        {
            "placeId": "c3",
            "category": "Cafe",
            "postalCode": "300-0003",
            "rating": 4.7
        }
    ],
    "minCategoryListings": 1,
    "minExpectedListings": 0.5,
    "minDeficit": 0.5
};

// Run the Actor and wait for it to finish
const run = await client.actor("practical_ai_lab_jp/pal-maps-business-coverage-gaps").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 = {
    "listings": [
        {
            "placeId": "a1",
            "category": "Cafe",
            "postalCode": "100-0001",
            "rating": 4.4,
        },
        {
            "placeId": "a2",
            "category": "Cafe",
            "postalCode": "100-0002",
            "rating": 4.1,
        },
        {
            "placeId": "a3",
            "category": "Cafe",
            "postalCode": "100-0003",
            "rating": 4.6,
        },
        {
            "placeId": "a4",
            "category": "Gym",
            "postalCode": "100-0004",
            "rating": 4.2,
        },
        {
            "placeId": "a5",
            "category": "Gym",
            "postalCode": "100-0005",
            "rating": 3.9,
        },
        {
            "placeId": "b1",
            "category": "Cafe",
            "postalCode": "200-0001",
            "rating": 4,
        },
        {
            "placeId": "b2",
            "category": "Cafe",
            "postalCode": "200-0002",
            "rating": 4.3,
        },
        {
            "placeId": "b3",
            "category": "Cafe",
            "postalCode": "200-0003",
            "rating": 4.5,
        },
        {
            "placeId": "b4",
            "category": "Gym",
            "postalCode": "200-0004",
            "rating": 4,
        },
        {
            "placeId": "b5",
            "category": "Gym",
            "postalCode": "200-0005",
            "rating": 4.4,
        },
        {
            "placeId": "c1",
            "category": "Cafe",
            "postalCode": "300-0001",
            "rating": 4.2,
        },
        {
            "placeId": "c2",
            "category": "Cafe",
            "postalCode": "300-0002",
            "rating": 4,
        },
        {
            "placeId": "c3",
            "category": "Cafe",
            "postalCode": "300-0003",
            "rating": 4.7,
        },
    ],
    "minCategoryListings": 1,
    "minExpectedListings": 0.5,
    "minDeficit": 0.5,
}

# Run the Actor and wait for it to finish
run = client.actor("practical_ai_lab_jp/pal-maps-business-coverage-gaps").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 '{
  "listings": [
    {
      "placeId": "a1",
      "category": "Cafe",
      "postalCode": "100-0001",
      "rating": 4.4
    },
    {
      "placeId": "a2",
      "category": "Cafe",
      "postalCode": "100-0002",
      "rating": 4.1
    },
    {
      "placeId": "a3",
      "category": "Cafe",
      "postalCode": "100-0003",
      "rating": 4.6
    },
    {
      "placeId": "a4",
      "category": "Gym",
      "postalCode": "100-0004",
      "rating": 4.2
    },
    {
      "placeId": "a5",
      "category": "Gym",
      "postalCode": "100-0005",
      "rating": 3.9
    },
    {
      "placeId": "b1",
      "category": "Cafe",
      "postalCode": "200-0001",
      "rating": 4
    },
    {
      "placeId": "b2",
      "category": "Cafe",
      "postalCode": "200-0002",
      "rating": 4.3
    },
    {
      "placeId": "b3",
      "category": "Cafe",
      "postalCode": "200-0003",
      "rating": 4.5
    },
    {
      "placeId": "b4",
      "category": "Gym",
      "postalCode": "200-0004",
      "rating": 4
    },
    {
      "placeId": "b5",
      "category": "Gym",
      "postalCode": "200-0005",
      "rating": 4.4
    },
    {
      "placeId": "c1",
      "category": "Cafe",
      "postalCode": "300-0001",
      "rating": 4.2
    },
    {
      "placeId": "c2",
      "category": "Cafe",
      "postalCode": "300-0002",
      "rating": 4
    },
    {
      "placeId": "c3",
      "category": "Cafe",
      "postalCode": "300-0003",
      "rating": 4.7
    }
  ],
  "minCategoryListings": 1,
  "minExpectedListings": 0.5,
  "minDeficit": 0.5
}' |
apify call practical_ai_lab_jp/pal-maps-business-coverage-gaps --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,practical_ai_lab_jp/pal-maps-business-coverage-gaps"
        }
    }
}
```

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/RXy8LG1gyqzxczh6A/builds/15UTdfH3gDQbRgcBR/openapi.json
