Google Maps Service Opportunity Scorer
Pricing
from $8.40 / 1,000 business assessments
Google Maps Service Opportunity Scorer
Prioritize Google Maps prospect research with an explainable business assessment. Score source-observed website, rating and listing gaps, separate unknown fields, and exclude closed locations.
Google Maps Service Opportunity Scorer — turn an existing Maps prospect batch into an evidence-linked outreach queue
Feed it a Google Maps places batch you already scraped and get back one explainable business assessment per unique place — missing website, low rating with sufficient review volume, absent owner description, and sparse listing, each evidence-linked and weighted into an opportunityScore — never a revenue guess or a claim of independent verification.

This Actor does not scrape Google Maps. It reads places already extracted by a Maps scraper — most naturally Google Maps Scraper or Google Maps with Contact Details — via Dataset, run, or inline items, and scores each unique place against an explicit, user-adjustable service playbook. It is a research-prioritization tool for local marketing agencies working from a batch they already collected — it is not an audience-similarity score, a revenue predictor, or an email verifier.
What you get
For each distinct place found across your source batch, you get one business assessment record containing:
entityId,placeId,cid— the place's stable identity, bounded and type-validated.signals— a per-signal state for each of the four playbook checks:missingWebsite,lowRating,missingOwnerDescription,sparseListing, each independently resolved todataset_missing,observed_blank,observed_absent,present,healthy,observed_low,unproven_low_rating,invalid,invalid_type,sparse, orsufficient— never a bare true/false that hides why.opportunityScore,opportunityScoreNormalized,weightTotal— the raw sum of triggered signal weights, that sum normalized against the total of all four configured weights, and the exact denominator used.statusandaction—closed_excluded,high_opportunity,moderate_opportunity,low_opportunity,healthy_listing, orinsufficient_data, each paired with a concrete next step.includedInOutreachQueue— a convenience boolean mirroringstatus.evidence— up to 50 entries, each citing the literal source field, the exact rule that fired, and the source row it came from.confidenceandpartial— the fraction of the four signals that resolved, and a composite disclosure flag for any conflict, truncation, or unresolved signal.- Honest diagnostics for everything else — a row with no
placeId/cid, an oversized identity, or a place with no assessable field, are all free rows, never silently scored as "healthy."
What this Actor explicitly is not: no revenue estimate, no lead-conversion probability, no independent confirmation beyond what the source dataset recorded. A dataset_missing website field means the source row didn't carry that column — never proof the business itself lacks a website.
Who uses it
- Local marketing and SEO agencies triaging a batch of Maps prospects by an explicit, adjustable playbook instead of eyeballing spreadsheets row by row.
- Outreach and business-development teams who need a defensible reason a place made the queue, rather than an opaque lead score.
- Franchise and multi-location auditors scoring their own portfolio for missing websites, thin listings, or low ratings with enough review volume to act on.
- Data-quality teams auditing an upstream Maps scrape, using the per-signal states as a signal for how clean the underlying data actually is.
Not a scraper: point it at an existing places Dataset from an upstream Actor, most naturally Google Maps Scraper (users30d=37173, successfulRuns30d=3,751,440) or Google Maps with Contact Details (users30d=3240, successfulRuns30d=72,898). This Actor never starts either one, and never fetches a page from the open web.
How to run
- Console (fastest for a one-off run). Open the Actor, go to Input, paste rows into Source records (inline) or fill in Source Dataset, leave defaults, hit Start.
- From an existing Maps Dataset. Put its Dataset ID in
datasetId— read with your own run's permissions, no separate authorization step. - Chained via Integrations. Add this Actor as an "Actor/task run" integration on your upstream Maps-scraper Actor; map
{{resource.defaultDatasetId}}into this Actor'sdatasetIdfield explicitly (see Integration recipes) for aREADgrant — a bare Dataset ID or webhook payload alone only requests access, it never guarantees it. - Via the API. Call
POST /v2/acts/Mw6Fno8qkRJUzgylV/runs(orrun-sync-get-dataset-items) withdatasetId(oritems) and, for a spending ceiling, the run optionmaxTotalChargeUsd. - Tune the playbook.
minimumReviews,lowRatingThreshold, andweightscontrol what counts as an opportunity and how heavily each signal is weighted — defaults are 10 reviews, a 3.5 threshold, and 40/30/15/15 weights. - Read the results. One row per unique place lands in the run's default Dataset, with a matching
OUTPUT/SUMMARYrecord describing delivery, billing, and coverage.
Every run is independent processing — see Operating guide for what that means for re-running against the same batch.
Pricing
Pay-per-event, one event type: result-found ("Business assessment" in the platform's pricing UI — the primary pricing noun used throughout this listing, matching this product's own noun field) — one delivered business assessment (one unique place). There is no start fee, and every free diagnostic (a missing identity, an insufficient-data row, a budget stop, an empty batch) requests no charge event at all.
| Tier | Price per business assessment | Discount |
|---|---|---|
| Free (default) | $0.012 | — |
| Bronze | $0.0108 | 10% |
| Silver | $0.0096 | 20% |
| Gold | $0.0084 | 30% |
| Platinum | $0.0084 | 30% |
| Diamond | $0.0084 | 30% |
These are Apify's platform-wide account pricing tiers — not a volume schedule this Actor invents — assigned automatically by your account's usage; you don't select one. One business assessment is one unique place across the whole batch, never billed per duplicate source row.
A note on the evidence in this README: every output example below comes from the product owner's own verification runs against this Actor's accepted build — cost to the owner, not sales or customer revenue.
Set the platform's own Max total charge (USD) run option to cap spend. If testing the zero-paid path via a raw API call, do not rely on literal maxTotalChargeUsd: 0 — the platform's own API-level cap-normalization behavior means a positive value below every tier's price only reliably guarantees zero paid results when the effective cap the run actually enforces stays at that value (inspect it after the run starts) — see Evidence and boundaries for the real recorded run demonstrating this.
Input contract
| Field | Type | Default | Notes |
|---|---|---|---|
items | array (JSON editor), max 1000 entries | prefilled real example | Inline source records. Ignored if datasetId, runId, or a resolvable payload.resource is also given. |
datasetId | string (Dataset picker, READ) | — | Dataset ID from a Maps-scraper run. Read with your own run's permissions. Overrides inline items. |
runId | string, max 64 chars | — | An Actor run ID to read instead of a Dataset ID; its default Dataset resolves under your own permissions — no upstream Actor started. |
payload | object (JSON editor) | — | Optional ACTOR.RUN.SUCCEEDED-shaped payload carrying resource.defaultDatasetId/resource.id. The datasetId resource picker is the recommended way to request access; a bare payload reference alone does not grant it. |
maxInputRows | integer | 100 | Hard cap on source records examined before grouping (1–1000). Still bounded to 4 MiB total / 64 KiB per row. |
maxResults | integer | 10 | Max useful results this run can deliver (1–1000), independent of the platform's Max total charge option. |
minimumReviews | integer | 10 | A low totalScore only counts when reviewsCount is at least this many; below it, the rating is unproven_low_rating, never scored either way. |
lowRatingThreshold | number | 3.5 | totalScore strictly below this, plus minimumReviews met, triggers the low-rating signal. |
weights | object (JSON editor) | {missingWebsite:40, lowRating:30, missingOwnerDescription:15, sparseListing:15} | Points added per triggered signal. Must not sum to zero across all four — an all-zero configuration is rejected, never silently defaulted. |
Source precedence, exactly: an explicit datasetId or payload.resource.defaultDatasetId (they must agree if both are present) beats a runId or payload.resource.id, which beats inline items. A dataset/run source is never merged with inline rows. A malformed or conflicting reference is rejected as invalid input, never silently downgraded to the inline example. Private-Dataset access is conditional: the resource picker or an authorized Integrations mapping can request a READ grant, but neither one guarantees the grant is actually honored by the platform — a bare Dataset ID string or webhook resource.defaultDatasetId reference alone never grants access to data you aren't already permitted to read.
Accepted row fields (any subset present is used; nothing is strictly required except a place identity):
| Field | Role |
|---|---|
placeId / cid | Primary entity identity. placeId (string, ≤500 chars) is preferred; cid (string or finite number) is used when placeId is absent. Over the bound, the row is rejected outright (oversized_identity), never truncated. Neither present is a free missing_identity diagnostic. |
website | dataset_missing (key absent — unknown), observed_absent (explicit blank — a real signal), present (non-blank string), or invalid_type (non-string, non-blank — never scored present). |
totalScore / reviewsCount | Google's own rating (0–5) and review count (non-negative integer). Both must be present and well-typed to resolve; unproven_low_rating applies when the score is low but volume is below minimumReviews. |
ownerDescription | Same four-state pattern as website. |
categoryName / categories / address / url | Feed the composite sparse-listing check: only fields the source dataset actually carried (present or observed-absent) count toward "how many of the 3 core fields are present" — columns missing entirely contribute zero either way. |
permanentlyClosed / temporarilyClosed | Either true excludes the place from outreach entirely, regardless of every other signal. |
title | Pass-through display text, bounded to 500 chars and never assessment evidence on its own — a bare title with no other informative field is a free insufficient_source_data diagnostic. |
scrapedAt | Picks the canonical occurrence when duplicate rows share the same identity — most recent valid timestamp wins, else the last occurrence deterministically. |
found, failed, status, error, errors | An explicit hard-failure signal (found: false, failed: true, or a known failure-alias status) is always a free source_reported_failure diagnostic, even with found: true/partial: true also present. A non-empty error/errors alone is a harmless warning ONLY when the same row also explicitly carries found: true and partial: true; otherwise it still produces the free diagnostic. |
Hard read bounds, always enforced regardless of maxInputRows: at most 1000 source rows, at most 4 MiB of total source JSON per run, at most 64 KiB per individual row (an oversized row is dropped and disclosed). This Actor reads only the fixed https://api.apify.com origin with your run's own token — it never fetches an arbitrary URL and never starts an upstream Actor.
Output examples
Every example below is a real record from an actual accepted run of this Actor's build 0.1.3 (Actor ID Mw6Fno8qkRJUzgylV, build e0w6gOHVNRhY37xqS), reproduced exactly — nothing is hand-edited. Source dates come from scrapedAt (2026-09-22T23:07:38–42Z); the runs were processed 2026-09-23 — a saved snapshot, not a live re-check.
1. First useful assessment — a full, real dataset row
This run used the Actor's own real inline prefill: 3 real London museum listings. Both diagrams on this page illustrate this exact run — the same 3 real places, all delivered as paid results.
Actor input (first of three real prefill rows; abbreviated to the fields this Actor reads):
{"title": "Natural History Museum","ownerDescription": null,"categoryName": "Natural history museum","address": "Cromwell Rd, South Kensington, London SW7 5BD, United Kingdom","website": "https://www.nhm.ac.uk/","totalScore": 4.6,"reviewsCount": 39058,"permanentlyClosed": false,"temporarilyClosed": false,"placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","url": "https://www.google.com/maps/search/?api=1&query=Natural%20History%20Museum&query_place_id=ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","scrapedAt": "2026-09-22T23:07:38.397Z","_exampleProvenance": {"sourceActorId": "nwua9Gu5YrADL7ZDj","sourceRunId": "5VeVFvxu0MQhPBrWY","sourceDatasetId": "WJkSy7pXIRxQlwPvn","curatedPublicSnapshot": true,"redaction": "Allowlisted public business/product fields only; review excerpts and social captions truncated; no reviewer identities."}}
First Dataset output row (of three delivered from this run):
{"entityId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","found": true,"status": "low_opportunity","action": "add_to_outreach_queue_low_priority","confidence": 1,"sourceRowCount": 1,"placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","title": "Natural History Museum","titleTruncated": false,"opportunityScore": 15,"opportunityScoreNormalized": 15,"weightTotal": 100,"signals": {"missingWebsite": "present","lowRating": "healthy","missingOwnerDescription": "observed_absent","sparseListing": "sufficient"},"includedInOutreachQueue": true,"observedAt": "2026-09-22T23:07:38.397Z","sourceObservationWindow": { "earliest": "2026-09-22T23:07:38.397Z", "latest": "2026-09-22T23:07:38.397Z", "validCount": 1, "totalRowCount": 1 },"evidence": [{ "field": "website", "value": "present", "rule": "present: source recorded a non-blank website value in this row", "sourceRowIndex": 0 },{ "field": "totalScore", "value": 4.6, "rule": "healthy_rating: source-observed totalScore 4.6 and reviewsCount 39058 do not meet the low-rating threshold (3.5)", "sourceRowIndex": 0 },{ "field": "ownerDescription", "value": null, "rule": "observed_absent: source-observed empty owner description in this row", "sourceRowIndex": 0 },{ "field": "categoryName/address/url", "value": 3, "rule": "sufficient_listing: source-observed listing has 3 of 3 core fields (category, address, url) present in this row", "sourceRowIndex": 0 }],"businessPartial": false,"sourceRowIndexes": [0],"partial": false,"sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },"product": "google-maps-service-opportunity-scorer","schemaVersion": "1.0","sourceDatasetId": null,"sourceRunId": null,"suppliedSourceRunId": null,"sourceRunIdVerified": null,"processedAt": "2026-09-23T05:37:55.035Z"}
observedAt/sourceObservationWindow reflect the sole contributing occurrence's real, whitespace-trimmed scrapedAt — earliest/latest are identical here because only one row contributed.
opportunityScore: 15 reflects only the missingOwnerDescription signal triggering (weight 15 of the default 40/30/15/15 playbook) — the website is present and the rating is healthy, so this museum is a low-priority outreach candidate, not a strong lead. opportunityScoreNormalized: 15 equals the raw score here because the default weights already sum to 100.
2. Derived partial-flag control — same real values, an explicit incompleteness flag injected
This is not a demonstration of removed columns or actual upstream data incompleteness. It is the identical real discovery batch as example 1, with each source row's own partial field deliberately set to true (and found: true added), to exercise this Actor's disclosed-incompleteness path under a controlled, known input. Every underlying value is identical and real; only the incompleteness flag is a deliberate test control, not an observed condition from any actual scrape.
Actor input (identical to example 1, except "found": true, "partial": true added):
{"title": "Natural History Museum","ownerDescription": null,"categoryName": "Natural history museum","address": "Cromwell Rd, South Kensington, London SW7 5BD, United Kingdom","website": "https://www.nhm.ac.uk/","totalScore": 4.6,"reviewsCount": 39058,"permanentlyClosed": false,"temporarilyClosed": false,"placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","url": "https://www.google.com/maps/search/?api=1&query=Natural%20History%20Museum&query_place_id=ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","scrapedAt": "2026-09-22T23:07:38.397Z","_exampleProvenance": {"sourceActorId": "nwua9Gu5YrADL7ZDj","sourceRunId": "5VeVFvxu0MQhPBrWY","sourceDatasetId": "WJkSy7pXIRxQlwPvn","curatedPublicSnapshot": true,"redaction": "Allowlisted public business/product fields only; review excerpts and social captions truncated; no reviewer identities."},"found": true,"partial": true}
Resulting row (businessPartial: true, partial: true — otherwise identical to example 1's row):
{"entityId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","found": true,"status": "low_opportunity","action": "add_to_outreach_queue_low_priority","confidence": 1,"sourceRowCount": 1,"placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","title": "Natural History Museum","titleTruncated": false,"opportunityScore": 15,"opportunityScoreNormalized": 15,"weightTotal": 100,"signals": {"missingWebsite": "present","lowRating": "healthy","missingOwnerDescription": "observed_absent","sparseListing": "sufficient"},"includedInOutreachQueue": true,"observedAt": "2026-09-22T23:07:38.397Z","sourceObservationWindow": { "earliest": "2026-09-22T23:07:38.397Z", "latest": "2026-09-22T23:07:38.397Z", "validCount": 1, "totalRowCount": 1 },"evidence": [{ "field": "website", "value": "present", "rule": "present: source recorded a non-blank website value in this row", "sourceRowIndex": 0 },{ "field": "totalScore", "value": 4.6, "rule": "healthy_rating: source-observed totalScore 4.6 and reviewsCount 39058 do not meet the low-rating threshold (3.5)", "sourceRowIndex": 0 },{ "field": "ownerDescription", "value": null, "rule": "observed_absent: source-observed empty owner description in this row", "sourceRowIndex": 0 },{ "field": "categoryName/address/url", "value": 3, "rule": "sufficient_listing: source-observed listing has 3 of 3 core fields (category, address, url) present in this row", "sourceRowIndex": 0 }],"businessPartial": true,"sourceRowIndexes": [0],"partial": true,"sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },"product": "google-maps-service-opportunity-scorer","schemaVersion": "1.0","sourceDatasetId": null,"sourceRunId": null,"suppliedSourceRunId": null,"sourceRunIdVerified": null,"processedAt": "2026-09-23T05:38:09.129Z"}
Notice the assessment itself is unchanged from example 1; only businessPartial/partial flip to true because the source row disclosed its own incompleteness — exactly what a genuinely partial real scrape would produce. This "partial-columns" fixture is a historical folder-name label only, sourced from this actor's own adversarial-provenance record: the actual test retains every real value and injects found:true/partial:true as a controlled flag, never removing or blanking a real column.
3. Below-price run — free budget_stopped diagnostic, no partial charge
[{"product": "google-maps-service-opportunity-scorer","schemaVersion": "1.0","status": "budget_stopped","reason": "Run charge limit cannot cover any result; no source was read.","found": false,"processedAt": "2026-09-23T05:38:20.929Z"}]
This run used the platform run option maxTotalChargeUsd: 0.005 — below every tier's per-assessment price (even Gold/Platinum/Diamond's $0.0084). The Actor never reads the source batch, delivers zero paid assessments, and writes this one free diagnostic. See Evidence and boundaries for the API-level zero-cap caveat.
4. Empty batch — free no_results diagnostic
Actor input:
{ "items": [], "maxInputRows": 100, "maxResults": 10 }
Dataset output:
[{"product": "google-maps-service-opportunity-scorer","schemaVersion": "1.0","status": "no_results","found": false,"reason": "no_usable_rows_in_batch","sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },"processedAt": "2026-09-23T05:38:30.802Z"}]
An empty or entirely-unusable batch is a free diagnostic, never a silent zero-row success and never a charge.
Field dictionary
Per-assessment result fields:
| Field | Type | Meaning |
|---|---|---|
entityId | string | placeId, or cid:<cid> when placeId is absent — stable key for this place. |
found | boolean | true on every billed assessment; false on every free diagnostic. |
status | string | closed_excluded, high_opportunity, moderate_opportunity, low_opportunity, healthy_listing, or insufficient_data on an assessment; a diagnostic reason (invalid_row, source_reported_failure, missing_identity, oversized_identity, insufficient_source_data, invalid_domain_options, budget_stopped, no_results, max_results_reached) on a free row. |
action | string | skip_outreach_closed, add_to_outreach_queue_priority, add_to_outreach_queue, add_to_outreach_queue_low_priority, manual_verification_needed, or no_action_needed. |
placeId / cid | string/number, null | Bounded, type-validated identity fields; a malformed non-primary identity is nulled rather than echoed verbatim. |
title / titleTruncated | string/null, boolean | Pass-through text bounded to 500 chars; true when truncated (a wrong-typed title is nulled instead, disclosed via evidence and partial: true). |
opportunityScore | number | Raw sum of triggered signal weights — not itself compared against the fixed thresholds. |
opportunityScoreNormalized | number | 100 * opportunityScore / weightTotal — compared against high_opportunity (≥50) / moderate_opportunity (≥20). |
weightTotal | number | Sum of ALL four configured weights, including unresolved signals — so an unresolved signal's weight can't inflate the percentage attributed to signals that did resolve. |
signals | object | { missingWebsite, lowRating, missingOwnerDescription, sparseListing }, each one of dataset_missing, observed_blank, observed_absent, present, healthy, observed_low, unproven_low_rating, invalid, invalid_type, sparse, sufficient. |
includedInOutreachQueue | boolean | Convenience flag mirroring status. |
confidence | number | Fraction (0–1) of the four signals that resolved to an assessable value. |
evidence | array | Up to 50 entries, each { field, value, rule, sourceRowIndex }. |
sourceRowCount / sourceRowIndexes | number / array | True count and (capped at 50) list of contributing rows, including failed duplicates sharing this identity. |
businessPartial / partial | boolean | businessPartial is this module's own signal; partial also folds in run-level source incompleteness — check partial if you only check one. |
sourceCoverage | object | Run-level: { complete, reason, droppedOversized }. |
sourceDatasetId, sourceRunId, suppliedSourceRunId, sourceRunIdVerified | string/null, boolean/null | Where the batch came from; sourceRunId set only when independently verified. |
processedAt | string (ISO 8601) | When processed — not when scraped (see scrapedAt in your own rows). |
observedAt | string (ISO 8601), null | The latest strictly calendar-valid scrapedAt across every accepted occurrence contributing to this entity (not only the canonical row), computed before any display/evidence cap. The exact captured text, whitespace-trimmed only — never re-serialized, never re-formatted, never a processing-time/"now" fallback. null when no contributing occurrence carried a valid scrapedAt. |
sourceObservationWindow | object | { earliest, latest, validCount, totalRowCount } — earliest/latest are the raw scrapedAt strings as originally reported by the earliest/latest-valid occurrence, whitespace-trimmed only (never re-serialized/re-formatted); validCount is how many contributing occurrences had a strictly valid scrapedAt; totalRowCount is how many accepted occurrences contributed to this entity at all — validCount is always ≤ totalRowCount, since an occurrence with an invalid, missing, or date-only scrapedAt still counts toward totalRowCount but never toward validCount. |
reason / error | string | Present on free diagnostic rows only. |
Complete runtime OUTPUT/SUMMARY field table (the Key-value store record under both the OUTPUT and SUMMARY keys — identical payload, two conventional key names; no metadata field exists in this runtime's real output):
| Field | Meaning |
|---|---|
schemaVersion | This product's output schema version string ("1.0"). |
product | "google-maps-service-opportunity-scorer". |
outcome | delivered, budget_stopped, max_results_reached, no_results, diagnostic, or failed. |
errorCode | Short internal code on a non-nominal outcome, or null. |
fatalFailure | true only when outcome === "failed". |
deliveryUncertain | A Dataset-write or charge acknowledgment is uncertain, including FREE_WRITE_FAILED; raw rows can exceed confirmed counters. |
replayBlocked | true on either of two distinct refusal causes: the run's own result-found event counter didn't match its expected record (EXISTING_RESULT), or this run's claim key was already set from a prior attempt and automatic resurrection is unsupported (EXISTING_CLAIM). |
requested | Source rows actually read this run. |
usefulResults | Results found after oversized-row filtering, before budget/maxResults cap. |
delivered | Paid, confirmed assessments actually written. |
paid | Confirmed result-found charge events, cross-checked against delivered. |
free | Free diagnostic rows actually written — see Evidence and boundaries for how this can exceed the 200-row business-diagnostic cap. |
failed | Useful results dropped for exceeding the 64 KiB per-row limit (rare). |
sourcePartial / sourcePartialReason | Whether the source READ was incomplete, and why. |
sourceDroppedOversized | Source rows dropped for exceeding 64 KiB before reaching assessment logic. |
budgetStopped | true if the charge limit was reached before all useful assessments were delivered. |
resultsCapped | true if maxResults was reached before all useful assessments were delivered. |
businessPartialResults | Partial transform results counted before size, budget and result-cap filtering; not a delivered-result count. |
oversizedDiagnosticsDropped | Diagnostic rows dropped for exceeding 64 KiB (rare). |
diagnosticsTruncated | true if more diagnostics existed than the runtime's write cap could hold — see Evidence and boundaries for what this counts. |
partialOverall | true if any of the above fired — check this one field if you only check one. |
budgetMode | unlimited, finite, or local_no_charging. |
maxTotalChargeUsd | The effective run charge cap enforced, or null when unlimited/local. |
replaySafety | Fixed disclosure string: re-running is new processing, no cross-run dedup. |
businessSummary | { totalSourceRows, resultsCount, diagnosticsCount, statusBreakdown, observedAt } — diagnosticsCount counts only this product's own business diagnostics, never the run-level notices layered on top (see Evidence and boundaries). |
processedAt | ISO 8601 timestamp of this OUTPUT/SUMMARY write. |
Evidence and boundaries
Hard failure always wins, even against a contradictory found:true, partial:true claim. A row with found: false, failed: true, or a status matching a known failure alias (failed/error/timed-out/timedout/timeout/aborted, trim+lowercase-normalized) is always a free source_reported_failure diagnostic — a source cannot simultaneously claim "this row failed" and "this row is a usable partial result." A non-empty error/errors field with no hard-failure signal present is retained as a harmless warning ONLY when the same row explicitly carries both found: true and partial: true; in every other case it still produces the free source_reported_failure diagnostic.
Four-field-state discipline, never conflated. website, ownerDescription, and the sparse-listing component fields each resolve independently to dataset_missing (key genuinely absent — unknown, never a claim the business lacks it), observed_blank/observed_absent (key present but explicitly blank — a real, source-observed gap), invalid_type (key present but wrong type — ambiguous, never counted either way), or present. Only fields the source dataset actually carried can count toward a signal triggering; missing columns contribute zero points.
A low rating backed by too few reviews is unproven_low_rating, never healthy. A 2.1/5 score with 3 reviews contributes zero low-rating points (too few reviews to confidently act on) but is never scored or reported as healthy either, and always marks the result partial.
A bare title is never assessment evidence on its own. A row where every signal resolves to dataset_missing/observed_blank/invalid/invalid_type/unproven_low_rating is a free insufficient_source_data diagnostic, never a paid "healthy" or "sparse" claim.
A closed business is excluded from outreach outright, before any signal is even scored. permanentlyClosed: true or temporarilyClosed: true produces status: closed_excluded, opportunityScore: 0, and an empty signals object.
Duplicate identities are resolved deterministically, and a failed duplicate is disclosed, never scored. When multiple rows share the same placeId/cid, the row with the most recent valid scrapedAt is canonical (else the last occurrence). A duplicate that itself reported a hard failure is excluded from evidence, but disclosed via sourceRowCount and partial: true on the surviving result. Canonical selection is a separate mechanism from observedAt/sourceObservationWindow: canonical selection decides which one occurrence's display fields (title, address, etc.) are used; the observation window spans scrapedAt across every accepted occurrence sharing the identity, whether or not that occurrence is the canonical one.
Bounded, disclosed caps, applied only to display, never to the underlying determination: evidence capped at 50 entries; sourceRowIndexes capped at 50 (sourceRowCount is the true total); title/pass-through free-text fields bounded to 500 characters (truncated, never silently merged with another value); a malformed non-string value in a text field is nulled, never echoed verbatim; a blank string over a few characters is described by length in evidence (<blank string, length 65000>) rather than copied raw.
Business diagnostics are capped at 200 written rows — but total free rows for a run can legitimately exceed that, and businessSummary.diagnosticsCount counts business-only. The initial diagnostics array the shared runtime writes from consists of this product's own report.diagnostics PLUS the runtime's own partial_source/source_rows_dropped notices, combined and then sliced to 200 written rows; diagnosticsTruncated covers that COMBINED list, while businessSummary.diagnosticsCount counts only this product's own business diagnostics, never the runtime notices layered on top. Separately, later notices — result_dropped_oversized, budget_stopped, or max_results_reached — are written on top of that 200-row cap and are not counted against it, raising the total free-row count above 200. Verified against a real 1,000-row batch (999 synthetic source_reported_failure rows plus a real 3-place batch tail, maxResults: 1): exactly 200 business diagnostics were written, one further max_results_reached notice was added, for 201 total free rows (freeRows: 201) — never a blanket "200 free cap." businessSummary.diagnosticsCount in that same run reported 997 (the true diagnostic count before the 200-row write slice), not the count of rows actually written.
Budget behavior — read this before relying on a specific maxTotalChargeUsd value via the API. A positive maxTotalChargeUsd below the price of one assessment correctly delivers zero paid assessments and one free budget_stopped diagnostic (output example 3, $0.005 against a $0.0084 floor tier). Separately, the platform's own API-level cap-normalization behavior applies to a raw maxTotalChargeUsd: 0 value. A positive value below every tier's price only reliably guarantees zero paid results while the effective cap is preserved at that value — inspect the run's actual, effective options.maxTotalChargeUsd after it starts rather than assuming the requested value silently held.
Delivery is not transactional, and a FAILED run does not mean "nothing happened." Each assessment is written to the Dataset and then charged as two separate steps; a run failing between them can leave an effectively free, unconfirmed write with no automatic retry — no cross-run exactly-once guarantee. If pricing/charge-limit/event-counter state can't be verified mid-run, the run finalizes FAILED with a safe explanation. Inspect the original run's Dataset and OUTPUT/SUMMARY record before a manual retry — a blind retry after an ambiguous outcome can duplicate paid assessments.
Field values are read from the actual output, never assumed from the schema description alone. Every field, state, and count above was independently confirmed against this actor's own real evidence receipts — businessSummary.diagnosticsCount genuinely differs from free in the mixed-boundary receipt (997 vs 201), which only reading the real summary.json makes clear.
Decision routing
status: high_opportunity,action: add_to_outreach_queue_priority—opportunityScoreNormalized≥ 50. A strong outreach candidate.status: moderate_opportunity,action: add_to_outreach_queue—opportunityScoreNormalized≥ 20 and < 50.status: low_opportunity,action: add_to_outreach_queue_low_priority—opportunityScoreNormalized> 0 but below 20, as in output example 1.status: healthy_listing,action: no_action_needed— every resolved signal came back healthy/present/sufficient.status: insufficient_data,action: manual_verification_needed— score is 0 but at least one signal never resolved — distinct from a genuinely healthy listing.status: closed_excluded,action: skip_outreach_closed— the source marked this place closed; never scored, never queued.businessPartial: true/partial: true— disclosed incompleteness, a duplicate-row conflict, a truncated/invalid title, or an unresolved signal — worth a second glance.- Free diagnostic rows (
found: false) — never an assessment.missing_identity/oversized_identitymean no stable identity;insufficient_source_datameans no signal resolved;invalid_domain_optionsmeans your playbook input was rejected;budget_stopped/max_results_reachedmean a run-level cap was hit.
Commercial playbooks
Tiered outreach triage. Sort by opportunityScoreNormalized descending and work high_opportunity first — each row cites the exact evidence to open the conversation with.
Custom playbook per client vertical. Adjust weights per agency client — a restaurant client might weight lowRating heavier than missingWebsite. weightTotal keeps the scale comparable across configurations.
Portfolio health audits. Run your own franchise dataset through with default weights to surface listing gaps across locations, independent of any outreach intent.
Data-quality triage. A high proportion of invalid_type/dataset_missing signals signals your upstream Maps scraper is inconsistently capturing fields.
Integration recipes

This Actor's ID is Mw6Fno8qkRJUzgylV. A natural upstream source is Google Maps Scraper (Actor ID nwua9Gu5YrADL7ZDj); Google Maps with Contact Details works identically. The recipes below describe how to configure each mechanism — not a claim any specific trigger is currently installed. A saved public example is available in the Tasks tab.
1. Console-native chaining (no code). On your Maps-scraper's run page, open Integrations, add an "Actor/task run" action targeting this Actor, and let the Integrations UI populate the payload with {{resource.defaultDatasetId}}. Map that value explicitly into this Actor's datasetId field — not the generic payload pass-through alone — to request a READ grant. The mapping requests the grant; it does not itself guarantee the platform honors it.
2. Direct webhook (POST /v2/webhooks), full recipe. Configure a webhook on your upstream Maps-scraper for the ACTOR.RUN.SUCCEEDED event, scoped to that specific Actor via its condition.actorId, targeting this Actor's run-creation endpoint:
{"isApifyIntegration": true,"shouldInterpolateStrings": true,"eventTypes": ["ACTOR.RUN.SUCCEEDED"],"condition": { "actorId": "nwua9Gu5YrADL7ZDj" },"requestUrl": "https://api.apify.com/v2/acts/Mw6Fno8qkRJUzgylV/runs?maxTotalChargeUsd=0.50","doNotRetry": true,"headersTemplate": "{\"Authorization\": \"Bearer {{APIFY_TOKEN}}\"}","payloadTemplate": "{\"datasetId\": \"{{resource.defaultDatasetId}}\", \"payload\": {\"resource\": {\"defaultDatasetId\": \"{{resource.defaultDatasetId}}\", \"id\": \"{{resource.id}}\"}}, \"minimumReviews\": 5}"}
Submit this to POST https://api.apify.com/v2/webhooks with your own Authorization: Bearer <APIFY_TOKEN> header. The {{APIFY_TOKEN}} placeholder is not an implicit platform template variable resolved automatically at trigger time — it must be provisioned separately: filled in from your own secrets store at the moment you register the webhook. doNotRetry: true avoids a duplicate-charge risk from an automatic retry. datasetId is set explicitly — that field is what actually requests access; payload.resource alone is informational provenance only. A positive maxTotalChargeUsd on the request URL caps spend; avoid literal 0 (see Evidence and boundaries).
3. Generic automation tools (n8n, Zapier, Make, or your own tooling). Use a generic Apify/HTTP node to call run-sync-get-dataset-items on this Actor's ID with a JSON body containing datasetId and your chosen weights/minimumReviews/maxResults — a plain authenticated HTTPS POST, no native integration required.
4. Export / CRM-adjacent mapping. Every run's Dataset supports CSV/Excel/JSON export via the Console or GET /v2/datasets/{datasetId}/items?format=csv. entityId, title, status, action, opportunityScoreNormalized, and signals alone drive a working outreach queue.
Operating guide
- Confirm your source batch shape first. Spot-check rows against Input contract — each needs
placeIdorcid, or you'll mostly getmissing_identitydiagnostics. - Tune the playbook before a large run.
minimumReviews,lowRatingThreshold, andweightschange what counts as an opportunity — test on a smallmaxResultsfirst if you've changed the defaults. - Start with a small
maxResultson a new source to confirm output matches expectations before a full batch. - Set
maxTotalChargeUsdvia the platform run option, and check its effective value after the run starts. For a guaranteed-zero-paid API call, use a small positive value below $0.0084, not literal0— see Evidence and boundaries. - Read the
OUTPUT/SUMMARYrecord, not just the Dataset row count — it reportsdelivered/paid/free/failed, whetherbudgetStopped/resultsCappedended the run early, anddiagnosticsTruncated. RememberbusinessSummary.diagnosticsCountcounts only this product's own diagnostics, not run-level notices layered on top. - If a run finalizes
FAILED, inspect its Dataset andOUTPUTrecord before retrying — a blind retry can duplicate already-successful paid assessments. - Re-running is always new billing — no cross-run dedup key. Reserve it for a new or updated batch, not a routine safety retry.
FAQ
Does this Actor scrape Google Maps itself? No. It only reads an existing places batch you or an upstream Actor already collected — no open-web fetch, no upstream Actor started.
Can I use a source other than Google Maps Scraper? Yes — any source producing rows with placeId/cid and the fields in Input contract works, including Google Maps with Contact Details.
Does a high opportunityScore mean the business will convert? No. It reflects how many playbook signals triggered and how heavily they're weighted — a research-prioritization score, not a probability of commercial success, revenue, or lead conversion.
Why is a low rating sometimes not scored as an opportunity? When reviewsCount is below minimumReviews, the low rating is unproven_low_rating — too few reviews to trust, but never counted as healthy either.
Does dataset_missing mean the business doesn't have a website? No. It means the source row's website column was absent entirely — genuinely unknown. Only an explicit blank value (observed_absent) is a real signal.
Does re-running the same batch avoid double-billing? No. Every run is independent with no cross-run deduplication.
Is the "partial-columns" style example a demonstration of missing upstream data? No — see output example 2. Same real values as the healthy example; only partial/found are deliberately set as a controlled test.
Why did one run report 201 free rows against a "200 diagnostics" cap, and diagnosticsCount showed 997? A separate run-level notice (max_results_reached) is written on top of the 200-row cap, not counted against it. businessSummary.diagnosticsCount reported the true 997 count before that 200-row write slice — see Evidence and boundaries.
Sources and rights
This Actor reads only Datasets and runs your own Apify account token already has permission to read; it never uses another user's credentials and never starts an upstream Actor on your behalf. The public prefilled example is a curated, non-sensitive real snapshot of three London museum listings from a Google Maps scrape (sourceActorId nwua9Gu5YrADL7ZDj, sourceRunId 5VeVFvxu0MQhPBrWY, sourceDatasetId WJkSy7pXIRxQlwPvn), observed 2026-09-22, reused with its original values unchanged — a saved example, not a live re-check or a new fresh scrape. All output values here are copied verbatim from real runs of this Actor's accepted build; none are invented. These verification runs were the product owner's own cost, not customer sales or revenue evidence. This README does not claim to be currently public on the Apify Store, and does not claim the actor's current cloud build already serves it.
Related tools
Related tools for adjacent workflows in B2B lead generation and data enrichment.
| Actor | What it does |
|---|---|
| Website Contact Scraper — Public Emails & Phones | Pair it in the B2B lead generation and data enrichment workflow: Find public company-site emails, phones, and social profiles with page-level evidence, best-contact... |
| Company Lookup — Website & GLEIF Evidence | Pair it in the B2B lead generation and data enrichment workflow: Turn domains, company names, or exact LEIs into evidence-linked website and GLEIF observations with... |
| Lead List Qualifier - Contact, Hiring & Tech Signals | Pair it in the B2B lead generation and data enrichment workflow: Prioritize company domains with transparent contact, hiring, technology, and SEC-name signals |
This actor's own crosslinks.json entry lists exactly 3 related links, not 5, and the 3 real, verified links above are used as-is rather than padded to a higher count.