# Local Business Qualifier with Sources (`korado_labs/local-business-qualifier`) Actor

Find Google Maps businesses, qualify ratings and review counts, check websites, and report evidence-backed ownership. Unverifiable Google post freshness stays unknown.

- **URL**: https://apify.com/korado_labs/local-business-qualifier.md
- **Developed by:** [Korado Labs](https://apify.com/korado_labs) (community)
- **Categories:** Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

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

## Local Business Qualifier with Sources

Evidence-first Google Maps discovery and qualification. **Early release, version 0.3.**
Bounded discovery and evidence-backed qualification; missing evidence is never
treated as a pass or fabricated as fact. Coverage varies by business, location and
Google interface; this is not a guarantee of exhaustive or error-free results.

### What it does

- Searches Maps or accepts direct Google Maps place URLs; bounded discovery,
  scrolling and CID/place-ID deduplication, up to 100 businesses per run. Queries
  receive a fair first share; duplicate/unused shares are refilled.
- Reads rating and **exact** review count from the place header, with a
  location-ID-checked public page-state fallback adapted from the MIT-licensed
  gosom parser. No rounded count is used to make a threshold decision.
- Checks public business websites and a bounded number of same-site
  about/ownership/franchise pages, recursively prioritized from every fetched page.
  A 45-second per-site budget preserves partial evidence. Honors robots.txt before
  each redirect destination and stops at access challenges. A fresh isolated
  browser can retry HTTP 403/transport failures or render JS-only pages, with
  DNS-pinned public-network egress, byte/time limits, no login and no CAPTCHA
  bypass. A 429 rate limit is not retried.
- Classifies **this location** as franchise/corporate/independent only when an
  explicit first-party statement is found. Franchising offered by the brand is
  a separate nullable field, not a claim about a location.
- Reads actual Maps “From the owner” previews and expanded owner-post cards
  before falling back to Search. Expanded cards must match the exact 64-bit
  business CID and business author; customer updates are excluded. Search cards
  must match the location feature ID. A per-browser-session 15-minute circuit
  breaker stops repeated Search attempts after a challenge.
  Relative dates are intervals, not fabricated exact publication timestamps.
- Returns `qualified`, `rejected`, or `unknown`, per-check decisions, quotes,
  URLs and observation timestamps. Unknown JSON values are `null` and CSV cells
  are blank. Does not invent a score or fill missing ownership from brand names.

### Important limits

Google frequently changes its UI. Search is ranked and location-dependent, not an
exhaustive census. Unexposed posts remain `not_exposed`, never "no posts."
`maxPostAgeDays` passes only with a definitely recent observed profile post;
old/missing/ambiguous posts produce **unknown**, never a false pass or definitive
inactivity claim. `latestObservedPostAt` is populated only for exact timestamps.
Post date bounds are in each `posts[]` item.

Ownership rules intentionally favor low recall over false positives. "Family
owned," "independently operated," a franchise link, or many locations is not enough.
Website reachability is not a design, accessibility or SEO assessment. A domain's
marketing statements are evidence claims, not independent legal ownership records.
Sitewide or multi-location statements may be insufficient for a specific branch;
review sensitive decisions manually.

No CAPTCHA solver, login scraping or challenge bypass. The browser reports its
real Chromium version and platform (only the "Headless" marker is removed, which
otherwise makes Google omit review counts). An optional user-supplied proxy
(`proxyConfiguration`) is supported. Native Maps owner cards are tried first;
Google Search may still block requests, and a residential proxy is not a tested
guarantee of complete post coverage. Blocked checks stay **unknown**. Use only where you have the right to collect the public data;
review platform terms and applicable law before commercial operation.

### Inputs

See `examples/input.json` and `.actor/input_schema.json`. Optional filters:
`minRating`, `maxRating`, `minReviews`, `maxReviews`, `maxPostAgeDays`,
`requireWebsite`, `ownership`. Unset means no filter. All active criteria must
pass. An observed mismatch rejects; otherwise missing required evidence yields
unknown. Each run is bounded by `maxPlaces` and `maxRunSeconds`.

### Apify

The container's default command runs `python -m local_qualifier` with the official
Apify SDK. Input comes from the default key-value store `INPUT`; results go to
the default dataset and run diagnostics to `SUMMARY`.
`includeRejected=false` pushes only qualified rows but keeps all counts in SUMMARY.
No usable rows/all extraction failures/timeouts fail the Actor run with SUMMARY.

Apify Actor `Y2c2vdTFSr02vGbJE` uses version 0.3, tag `latest`.
Update with `apify login` (your own token) and `apify push` in this folder.

#### Pay-per-event charging

The code charges these events only when they are priced in Apify Console:

| Event | When it is charged |
| --- | --- |
| `qualified-lead` | A business passes every active filter and is saved |
| `business-checked` | Optional: every processed business, qualified or not |

Rejected/unknown rows are free unless `business-checked` is priced. Do **not**
price `apify-default-dataset-item`, or every rejected row is billed. Before
charging anything, the Actor reserves the full price of the row; when a user's
maximum charge cannot cover it, the run stops cleanly without billing a dropped
lead. See `docs/PRICING.md`.

### Self-hosted deployment

An optional Fly.io service is a separate deployment and is not updated when this
Apify Actor is rebuilt. Its single-worker SQLite queue is not horizontally scalable.
Self-hosted infrastructure incurs separate hosting costs.

### Development and tests

```bash
python -m venv .venv
.venv/bin/pip install -r requirements-dev.txt -c constraints.txt
export PYTHONPATH=src:vendor
.venv/bin/pytest -q
## In your own development environment:
.venv/bin/playwright install chromium
.venv/bin/python -m local_qualifier examples/input.json output
```

For a managed browser/CDP test, set `QUALIFIER_CDP_URL`; the pipeline creates and
closes only its own isolated context. Never include CDP credentials in artifacts.

The MIT-licensed Maps parser is pinned and attributed in `THIRD_PARTY_NOTICES.md`.
Runtime package versions are pinned in `requirements.txt`; full environment locks
and validation reports are included with the build.

See `docs/HANDOFF.md` for the actual cloud test results, access instructions and
confirmed Google data-access limitations. A running service is not the same as
production readiness: do not publish claims of reliable post/count coverage yet.

# Actor input Schema

## `searchQueries` (type: `array`):

What to look for on Google Maps, e.g. "dentists in Austin Texas". Up to 10 queries.

## `startUrls` (type: `array`):

Optional direct Google Maps place links (https://www.google.com/maps/place/...). Use search queries for discovery.

## `maxPlaces` (type: `integer`):

Maximum unique businesses to check (1–100). Queries receive a fair first share; unused shares are refilled from other queries.

## `minRating` (type: `number`):

Only qualify businesses rated at least this many stars (0–5).

## `maxRating` (type: `number`):

Only qualify businesses rated at most this many stars (0–5).

## `minReviews` (type: `integer`):

Only qualify businesses with at least this many Google reviews.

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

Only qualify businesses with at most this many Google reviews.

## `maxPostAgeDays` (type: `integer`):

Require an observed owner-authored Google profile post within this interval. Reads Maps owner previews and expanded cards first, with a location-checked Search fallback. Missing, old-only or blocked evidence stays unknown.

## `requireWebsite` (type: `boolean`):

Only qualify businesses whose Google profile lists a website that responds.

## `ownership` (type: `string`):

Requires an explicit first-party independence, franchise or corporate-ownership statement. Supports varied wording; family ownership, brand size and franchise offers alone do not establish branch ownership.

## `includeRejected` (type: `boolean`):

Also output rejected and unknown businesses with reasons. These do not incur qualified-lead charges, but may incur business-checked charges if that optional event is priced.

## `maxWebsitePages` (type: `integer`):

How many pages per business website to read for ownership evidence (1–8).

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

Optional proxy for Google pages. Native Maps owner-post extraction is tried before Search. A proxy is not a guarantee of access or complete post coverage.

## `pageTimeoutSeconds` (type: `integer`):

How long to wait for each Google page to load.

## `maxRunSeconds` (type: `integer`):

Hard limit for the whole run. Runs that hit it return partial results marked TIMED_OUT.

## Actor input object example

```json
{
  "searchQueries": [
    "dentists in Austin Texas"
  ],
  "startUrls": [],
  "maxPlaces": 20,
  "requireWebsite": false,
  "ownership": "any",
  "includeRejected": true,
  "maxWebsitePages": 4,
  "proxyConfiguration": {
    "useApifyProxy": false
  },
  "pageTimeoutSeconds": 45,
  "maxRunSeconds": 600
}
```

# Actor output Schema

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

One row per saved business, including qualification checks, source evidence and unknown values.

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

Processed counts, evidence coverage, warnings and partial-run status.

# 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 = {
    "searchQueries": [
        "dentists in Austin Texas"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("korado_labs/local-business-qualifier").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 = {
    "searchQueries": ["dentists in Austin Texas"],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("korado_labs/local-business-qualifier").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 '{
  "searchQueries": [
    "dentists in Austin Texas"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call korado_labs/local-business-qualifier --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,korado_labs/local-business-qualifier"
        }
    }
}
```

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/Y2c2vdTFSr02vGbJE/builds/dnKyHMAVIhBXHgGtz/openapi.json
