# Retail & Franchise Site Selection Intelligence (`adinfosys-labs/retail-site-opportunity-finder`) Actor

Find and rank underserved expansion zones for any retail or service business. Analyze direct and adjacent competitors, demand generators, transport access, and amenities. Export an auditable Excel report, interactive map, Dataset, JSON, and QGIS-ready GeoJSON layers.

- **URL**: https://apify.com/adinfosys-labs/retail-site-opportunity-finder.md
- **Developed by:** [Artashes Arakelyan](https://apify.com/adinfosys-labs) (community)
- **Stats:** 2 total users, 1 monthly users, 88.9% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.01 / 1,000 ranked opportunity zones

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

## Retail & Franchise Site Selection Intelligence

> **Release status:** v6.7 is the **cloud-validated v1.0 production release**. It combines the
> Phase 3 analysis engine with the Phase 4 client-facing outputs. Apify build `1.0.12` completed
> successfully, and the full Austin pharmacy acceptance run passed with `A / good` data quality.

### v6.7 transparent market-comparable screening

v6.7 adds configurable rules for the market-rent benchmark. The default search
radius is 1,500 m around the top zone, eligible area is 50–250 sqm, and a listing
must be verified, active, non-demonstration, first-floor, street-accessible, and
have an address, source URL, and verification date. At least three eligible
listings are required. Every rejected listing remains in the Excel report with
explicit exclusion reasons, preventing large buildings, distant premises, stale
listings, and incomplete records from silently distorting the median.

The production quality gate also prevents `A / good` from being reported when the adjacent-
competitor source fails. A direct-only ranking is capped at `B / moderate` and remains labelled
`partial_competitor_ranking` / `direct_only_adjacent_failed`.

### v6.6 production decision guards

v6.6 prevents demonstration, inactive, or unverified listings from receiving a
`GO` recommendation. A market rent median is produced only when at least three
verified, active, non-demonstration first-floor street-entry comparables are
available. If the adjacent-competitor query fails while direct competitors are
available, the analysis is labeled `partial_competitor_ranking` and reports
`direct_only_adjacent_failed` competition coverage.

### v6.5 property-economics extension

Optional `candidate_properties` can now be evaluated against the highest-ranked
opportunity zone. When properties are supplied, `business_economics` must include
a positive `projected_monthly_sales_amd`. The report calculates rent per square
metre, distance to the top zone, total occupancy cost, maximum affordable rent,
required sales, safety margin, a property-fit score, and a `GO`, `NEGOTIATE`, or
`REJECT` screening recommendation.

The Excel report adds `Candidate Properties` and `Property Economics` sheets.
Listing details and financial assumptions remain user-supplied and must be
verified before signing a lease.

### v6.4 — Resilient interactive-map delivery

v6.4 replaces the legacy OSM subdomain template with the policy-compliant
`https://tile.openstreetmap.org/{z}/{x}/{y}.png` URL. The map now detects `file://`
viewing, explains how to serve the report through localhost, and stops repeated tile requests after
multiple failures. Opportunity zones, competitors, demand generators, popups, and layer controls
remain usable on a neutral background when a basemap is unavailable.

Advanced users can set `basemap_url` and `basemap_attribution` in Actor input. The URL must be an
absolute HTTPS raster-tile template containing `{z}`, `{x}`, and `{y}`. Users remain responsible
for the selected provider's licence, attribution, usage limits, and credentials.

### v6.3 — Conservative competitor deduplication

v6.3 removes likely duplicate competitor POIs before spatial scoring. Two records are merged only
when they have the same normalized name, the same competitor type (`direct` or `adjacent`), and
are no more than 25 metres apart. Unnamed records, differently classified records, and same-name
outlets farther apart are retained.

The record with the richer OSM tag set is retained. JSON and XLSX outputs report the raw count,
final unique count, number removed, threshold, retained OSM reference, removed references, and
maximum cluster distance. The successful Yerevan bookstore dataset used during verification was
reduced conservatively from 64 to 63 competitors by merging one 5.2-metre duplicate pair.

### v6.2 — Persistent source-cache protection

v6.2 adds a persistent, input-specific source cache. Every successful direct-competitor,
adjacent-competitor, and demand-generator group is stored in the named
`retail-site-source-cache` key-value store. If a later live Overpass request still fails after
its normal retry, only that failed group is restored from its last successful snapshot.

Cached recovery is never hidden: affected sources are labelled `cached_after_failure`, their
snapshot timestamp and age are included in the audit trail, and an otherwise A-grade result is
capped at B / moderate reliability. A first successful run is required to seed the cache for a
specific location, radius, business category, and tag configuration.

### v6.1 — GeoJSON compatibility fix

v6.1 explicitly serializes every GeoJSON deliverable as standards-compliant JSON text before it
is written with the `application/geo+json` content type. This prevents Python dictionary syntax
from appearing in downloaded `.geojson` files and makes them directly readable by QGIS, web maps,
and standard JSON parsers.

### v6.0 — Phase 4 productization

v6.0 keeps the complete Phase 3 analysis engine and adds the first client-facing product layer:

- a `Decision Brief` worksheet with status, data-quality limits, ranked zones, strengths, and
  recommended validation actions;
- an interactive Leaflet HTML map with opportunity zones, direct competitors, adjacent
  competitors, and an optional demand-generator layer;
- a machine-readable `decision_brief` section in `summary.json`;
- expanded Dataset and Actor output schemas for commercial review;
- publication material and a release checklist for Apify Store deployment.

### v5.6 analysis-engine change

Direct and adjacent competitors are now queried independently on every run. The existing
`custom_fallback_tags` input name is retained for backward compatibility, but its non-primary
tags now define adjacent competitors rather than an emergency fallback query.

An Apify Actor that discovers and ranks expansion opportunity zones for a selected business
category in a city. The user provides a place and a business category; the Actor collects
OpenStreetMap data, builds analysis cells, measures competition and accessibility, and returns
decision-ready tables and geospatial files.

### Product promise

**Enter a city and a configurable retail or service business category. Get ranked underserved expansion zones with an explicit data-quality grade.**

This project replaces the earlier manual-input scoring prototype. Users are not expected to
collect facility coordinates or invent 0–100 site scores before running the Actor.

### Primary users

- retail and franchise expansion teams;
- commercial real-estate analysts;
- pharmacy, clinic, grocery, gym, café, bank, and EV-charging operators;
- location-intelligence and GIS consultants.

### MVP workflow

1. Geocode the requested study area.
2. Download relevant competitors and demand-generating POIs from OpenStreetMap/Overpass.
3. Generate regular analysis cells across the study area.
4. Calculate competitor density, nearest-competitor distance, transport access, and
   demand-generator density for every cell.
5. Rank opportunity zones using transparent, configurable weights.
6. Export a Dataset, XLSX report, GeoJSON layers, and summary JSON.

### Minimal input

```json
{
  "location_query": "Austin, Texas, USA",
  "business_category": "pharmacy",
  "analysis_mode": "discover_opportunity_zones"
}
```

See `examples/austin_pharmacy_input.json` for the complete MVP example.

Built-in presets currently cover pharmacy, supermarket, clinic, coffee shop, gym, EV charging,
bank, restaurant, bakery, hotel, and fuel station. Select `custom` to supply primary competitor
tags, broader adjacent-competitor tags, and business-specific demand-generator groups without changing code.

Example custom bookstore configuration:

```json
{
  "location_query": "Yerevan, Armenia",
  "business_category": "custom",
  "custom_osm_tags": [{"key": "shop", "value": "books"}],
  "custom_fallback_tags": [
    {"key": "shop", "value": "books"},
    {"key": "shop", "value": "stationery"}
  ],
  "adjacent_competitor_weight": 0.4,
  "custom_demand_generators": {
    "universities": {
      "role": "primary_demand",
      "tags": [{"key": "amenity", "value": "university"}]
    },
    "schools": {
      "role": "primary_demand",
      "tags": [{"key": "amenity", "value": "school"}]
    },
    "public_transport": {
      "role": "transport",
      "tags": [{"key": "highway", "value": "bus_stop"}]
    },
    "libraries": {
      "role": "support",
      "tags": [{"key": "amenity", "value": "library"}]
    },
    "cafes": {
      "role": "support",
      "tags": [{"key": "amenity", "value": "cafe"}]
    }
  }
}
```

### MVP outputs

- Default Dataset: ranked opportunity zones.
- `opportunity_zones.geojson`: all scored grid cells.
- `competitors.geojson`: discovered competing businesses.
- `demand_generators.geojson`: supporting POIs used in the score.
- `site_selection_report.xlsx`: recommendations, assumptions, and source-quality notes.
- `interactive_map.html`: client-facing map with layer controls and zone popups.
- `summary.json`: run metadata and key findings.

### Important interpretation

The Actor produces a screening analysis, not a final lease or investment recommendation. It uses
OpenStreetMap availability as a market and accessibility proxy. Optional candidate-property and
rent-economics inputs are supported, but those records are user-supplied and must be verified.
Census, traffic, network travel time, zoning, parcel constraints, and field inspection remain
external validation inputs.

### Development phases

See `TECHNICAL_SPEC.md` and `IMPLEMENTATION_PLAN.md`.

### Current implementation status

Phases 3 and 4 are implemented. The current runnable build:

- validates the Actor input and score weights;
- geocodes `location_query` with Nominatim;
- resolves the selected category preset;
- downloads competitors and demand-generating POIs through Overpass;
- requests each demand-generator group separately so one timeout does not erase successful
  results from the other groups;
- preserves partial demand data and reports success or failure for every group;
- waits after the first pass and retries only failed demand groups, without re-querying or
  duplicating groups that already succeeded;
- retries a failed competitor source after demand collection without repeating successful demand
  queries;
- persists successful source groups by location and configuration, then restores only failed
  groups from the latest matching snapshot on later runs;
- exposes `cached_after_failure`, cache age, and cache-restored group counts in JSON and XLSX,
  while capping cached results at B / moderate reliability;
- blocks final recommendations when competitor collection still fails: the missing competition
  weight stays unallocated and the remaining result is labelled `demand_only_diagnostic`;
- retries rate-limited requests across three public endpoints with backoff;
- applies category-specific primary and adjacent competitor definitions for every built-in or
  custom business configuration;
- queries direct and adjacent competitor definitions independently on every run, removes duplicate
  OSM objects, and always gives the direct classification precedence;
- removes likely duplicate competitor POIs using conservative same-name, same-type, 25-metre
  matching and publishes a complete merge audit;
- classifies primary-tag matches as direct competitors and broader-tag-only matches as adjacent
  competitors; adjacent businesses use a configurable relative weight (default 0.4);
- scores competition density with weighted competitors while nearest-distance scoring uses the
  nearest direct competitor;
- records the endpoint, query strategy, and exact competitor tags in the XLSX Data Quality sheet;
- grades source coverage from A to D and labels sparse-data rankings as low-confidence;
- assigns every demand-generator group a scoring role: `primary_demand`, `transport`, or `support`;
- marks flat scoring components inactive and redistributes their requested weight across active
  components instead of assigning an artificial neutral score;
- writes a diagnostic Excel report even when a
  source remains unavailable;
- generates a regular grid fully contained by the circular study radius;
- calculates competitor counts at each configured catchment radius and nearest-competitor distance;
- calculates demand, public-transport, and supporting-amenity counts;
- normalizes components within the study area and ranks zones with traceable weighted scores;
- writes ranked zones to the default Dataset;
- writes `source_data.json`, `summary.json`, `site_selection_report.xlsx`, and three GeoJSON layers
  to the key-value store.
- writes a client-facing interactive HTML map and structured decision brief while preserving the
  full source and methodology audit trail.

### Validated acceptance scenarios

The release has passed **53 automated tests**, three materially different acceptance scenarios,
and a complete Apify cloud-runtime acceptance run:

| Scenario | Verified result | Interpretation |
|---|---|---|
| Austin pharmacy — Apify cloud | Build `1.0.12`; data quality A; 24 competitors; 1,476 demand generators; 109 zones; complete ranking | Built-in category, retries, scoring, Dataset, XLSX, GeoJSON, JSON, and interactive-map outputs work end to end in the production runtime. |
| Tbilisi coffee shop | 694 direct competitors; 3,114 demand generators; adjacent `shop=coffee` source failed | The Actor preserved a useful direct-only result and labels it `B / moderate`, `partial_competitor_ranking`, and `direct_only_adjacent_failed`. |
| Yerevan custom bookstore | Custom direct, adjacent, and demand definitions completed; demonstration property blocked from `GO`; too few eligible rent comparables for a median | Custom business logic and the production property-economics guards behave conservatively. |

These runs validate the software paths, not the commercial truth of every output. OSM coverage,
public Overpass availability, address quality, and user-supplied property data still vary by market.

The result is a screening-level recommendation. It is not a final lease or investment decision:
demographics, parcels, zoning, rent, traffic, travel time, and field verification remain external
validation steps.

#### Local setup

```bash
python -m pip install -r requirements.txt
mkdir -p storage/key_value_stores/default
cp examples/austin_pharmacy_input.json storage/key_value_stores/default/INPUT.json
python -m unittest discover -s tests -v
python -m src
```

Inspect the first live-run outputs:

```bash
python -m json.tool storage/key_value_stores/default/summary.json
python -m json.tool storage/key_value_stores/default/source_data.json
open storage/key_value_stores/default/site_selection_report.xlsx
open storage/key_value_stores/default/interactive_map.html
open storage/key_value_stores/default/opportunity_zones.geojson
```

For reliable local basemap loading, serve the output directory over HTTP instead of opening the
map through a `file://` URL:

```bash
cd storage/key_value_stores/default
python3 -m http.server 8000
```

Then open `http://localhost:8000/interactive_map.html`. Stop the server with `Control+C`.

Only one `INPUT*.json` file may exist inside the default key-value-store directory. Keep backups
outside `storage/key_value_stores/default`.

### Deploy to Apify

Apify Actor versions use the `MAJOR.MINOR` format. Both numbers must be between 0 and 99. Do not
use a three-part value such as `1.0.0`; the CLI rejects it. The release configuration should contain:

```json
{
  "version": "1.0"
}
```

Every field in `.actor/input_schema.json`, including properties nested inside arrays and objects,
must include the metadata required by Apify. This release provides titles and descriptions for the
analysis mode, candidate sites, custom OSM tags, candidate properties, and comparable-rule fields.
Missing field metadata causes the Apify upload to succeed but the cloud build to fail during schema
validation. The schema also uses Apify-compatible `minimum` constraints instead of the unsupported
JSON Schema `exclusiveMinimum` keyword. The `output_formats` array uses the `select` editor with
`items.enum`, which renders Apify's supported predefined multiselect control.

The Actor output schema uses `actorOutputSchemaVersion: 1`, a schema title and description, and a
`properties` object containing the generated Dataset and key-value-store links.
The Dataset schema keeps `actorSpecification: 1`, defines `fields` as a JSON Schema object with
`type: object` and nested `properties`, and exposes the ranked-zone overview table.

From the project root, update the CLI, confirm authentication, rerun the tests, and push:

```bash
npm install -g apify-cli@latest
apify --version
apify login
python -m unittest discover -s tests -v
apify push
```

After the build succeeds in Apify Console, run at least the Austin example and verify:

1. the run finishes successfully and the Dataset contains ranked opportunity zones;
2. `summary.json` reports the expected status, source coverage, and reliability;
3. `site_selection_report.xlsx`, `interactive_map.html`, `source_data.json`, and all three
   GeoJSON layers are present in the default key-value store;
4. the run log and Data Quality worksheet disclose every failed, cached, or partial source;
5. the interactive map opens through its Apify output URL and its layer controls work.

The Austin cloud acceptance run completed this publication gate. Store review remains subject to
Apify's listing, pricing, account, and marketplace approval requirements.

# Actor input Schema

## `location_query` (type: `string`):

A geocodable place such as Austin, Texas, USA.

## `business_category` (type: `string`):

The business type whose expansion opportunities will be analyzed.

## `analysis_mode` (type: `string`):

Choose whether to discover and rank opportunity zones or compare user-supplied candidate sites.

## `search_radius_km` (type: `number`):

Radius around the geocoded study-area centre.

## `grid_cell_size_m` (type: `integer`):

Smaller cells increase detail and runtime.

## `catchment_radii_m` (type: `array`):

Distances used for competitor and POI counts.

## `top_n` (type: `integer`):

Number of top-ranked zones highlighted in reports.

## `candidate_sites` (type: `array`):

Optional named addresses or coordinates for comparison mode.

## `custom_osm_tags` (type: `array`):

Required only for the custom business category, for example \[{"key":"shop","value":"bakery"}].

## `custom_fallback_tags` (type: `array`):

Broader competitor definition collected independently on every run. Tags already listed as primary are removed; remaining matches are adjacent competitors.

## `adjacent_competitor_weight` (type: `number`):

Relative competition impact of adjacent businesses collected independently from direct competitors. Direct competitors always have weight 1.0.

## `custom_demand_generators` (type: `object`):

Named groups with role (primary_demand, transport, or support) and OSM tags. Legacy tag-list groups remain accepted.

## `weights` (type: `object`):

Advanced setting. Values must sum to 1.0.

## `basemap_url` (type: `string`):

Advanced setting. HTTPS raster-tile template containing {z}, {x}, and {y}. Provider terms and attribution remain the user's responsibility.

## `basemap_attribution` (type: `string`):

Visible attribution required by the selected basemap provider.

## `business_economics` (type: `object`):

Financial assumptions used to test candidate-property rent affordability.

## `candidate_properties` (type: `array`):

User-verified listings to evaluate against the top opportunity zone and business economics.

## `property_comparable_rules` (type: `object`):

Strict filters used only for the market rent benchmark. Rejected listings remain visible with exclusion reasons.

## `output_formats` (type: `array`):

Dataset is always produced; select additional export formats.

## Actor input object example

```json
{
  "location_query": "Austin, Texas, USA",
  "business_category": "pharmacy",
  "analysis_mode": "discover_opportunity_zones",
  "search_radius_km": 5,
  "grid_cell_size_m": 750,
  "catchment_radii_m": [
    1000,
    3000,
    5000
  ],
  "top_n": 10,
  "candidate_sites": [],
  "custom_osm_tags": [],
  "custom_fallback_tags": [],
  "adjacent_competitor_weight": 0.4,
  "custom_demand_generators": {},
  "weights": {
    "competition_gap": 0.4,
    "demand_proxy": 0.25,
    "transport_access": 0.2,
    "amenity_support": 0.15
  },
  "basemap_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
  "basemap_attribution": "© OpenStreetMap contributors",
  "business_economics": {
    "projected_monthly_sales_amd": 0,
    "target_occupancy_cost_ratio": 0.12,
    "monthly_other_occupancy_costs_amd": 0,
    "gross_margin_rate": 0.35
  },
  "candidate_properties": [],
  "property_comparable_rules": {
    "radius_m": 1500,
    "minimum_area_sqm": 50,
    "maximum_area_sqm": 250,
    "minimum_comparables": 3,
    "require_first_floor": true,
    "require_street_entrance": true
  },
  "output_formats": [
    "dataset",
    "xlsx",
    "geojson",
    "json"
  ]
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `sourceData` (type: `string`):

No description

## `excelReport` (type: `string`):

No description

## `interactiveMap` (type: `string`):

No description

## `opportunityZonesGeoJson` (type: `string`):

No description

## `competitorsGeoJson` (type: `string`):

No description

## `demandGeneratorsGeoJson` (type: `string`):

No description

## `summary` (type: `string`):

No description

# 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("adinfosys-labs/retail-site-opportunity-finder").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("adinfosys-labs/retail-site-opportunity-finder").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 adinfosys-labs/retail-site-opportunity-finder --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,adinfosys-labs/retail-site-opportunity-finder"
        }
    }
}
```

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/ieNujfY0SuaN1ZiPH/builds/2Msgb8sYxqcSVxlck/openapi.json
