Business Opportunity Finder avatar

Business Opportunity Finder

Under maintenance

Pricing

$10.00 / 1,000 business opportunity founds

Go to Apify Store
Business Opportunity Finder

Business Opportunity Finder

Under maintenance

Find businesses that have a problem you may be able to solve. Discover potential website, booking, contact, mobile, and digital presence opportunities from real businesses.

Pricing

$10.00 / 1,000 business opportunity founds

Rating

0.0

(0)

Developer

Nord

Nord

Maintained by Community

Actor stats

1

Bookmarked

1

Total users

0

Monthly active users

a day ago

Last modified

Categories

Share

Find businesses that have a problem you may be able to solve.

Business Opportunity Finder is a research and lead-discovery tool for agencies, consultants, and service providers who help local businesses improve their online presence. Give it a search — e.g. "dentists" in "Amsterdam", or "restaurants" in "Utrecht" — and it finds real local businesses, analyzes their websites, and surfaces observable, evidence-backed digital-presence gaps: a restaurant with no visible way to book a table, a dentist with no HTTPS or contact form, a business with no detectable website at all. Every result comes with the underlying evidence, not just a score — you decide which gaps are worth a closer look.

What this tool does not claim:

  • It does not guarantee leads, sales, or customers.
  • It does not guarantee that any single result is accurate — it reports observable evidence, not verified facts about a business's internal situation.
  • It does not know that a business "needs" a particular service — it only reports what it could and could not detect on that business's website.
  • It does not claim that every detected gap is commercially valuable to pursue.
  • It does not claim a business has no website just because none was found in discovery — see "What this product is / is not" and "Known limitations" below.

What it does

  1. Finds local businesses matching a search query and location, via a third-party Google Maps discovery Actor.
  2. Analyzes each business's candidate website — one HTTP request, homepage only — for signals like HTTPS, contact info, booking links, and social presence.
  3. Verifies whether that website actually belongs to the business, rather than being a directory or social-media page mistaken for one.
  4. Calculates two deterministic, rule-based scores from the observed facts: a digital-presence score and an opportunity score.
  5. Optionally qualifies higher-scoring businesses with an LLM (Anthropic Claude) for a qualitative second opinion — this step can never change the deterministic scores computed in step 4.
  6. Returns one structured result per business with the underlying facts, the deterministic scores and reasons, and (if run) the LLM qualification, so the evidence behind every result is visible.

Example use cases

People and teams who might use this tool's output as a starting point for their own research or outreach — these are possible uses of the output, not guaranteed outcomes:

  • Web design agencies looking for businesses with an outdated, unreachable, or missing website.
  • Booking / appointment software providers looking for businesses with no detected online booking capability.
  • SEO and digital marketing agencies looking for businesses with weak metadata, missing HTTPS, or other observable digital-presence gaps.
  • CRM / contact-form solution providers looking for businesses with no detected contact form or contact information.
  • Mobile / web optimization services looking for businesses whose sites are missing basic signals like a mobile viewport tag.

Example input

{
"query": "dentists",
"location": "Amsterdam",
"maxResults": 20
}

Only query and location are required. When run as an Apify Actor, maxResults can go up to 200. This is a different, independent setting from the optional MCP interface described further down, whose own maxResults is capped at 35 — do not confuse the two. See "Input" below for the full Actor input example and every configurable field.

What you get

Each result keeps three kinds of information separate, and never blends them:

  • Facts (business, website, signals) — what was actually discovered and observed: business details from the discovery source, plus website facts and signals from a single fetch of its homepage.
  • Deterministic analysis (digitalPresence, opportunity) — two rule-based scores computed from those facts using fixed, documented weights, with no AI involved. opportunity.reasons[] lists exactly which observed facts contributed to the score, and website.verificationReasons[] explains why a website was (or wasn't) judged to belong to the business.
  • Optional LLM qualification (llmQualification) — a separate, qualitative second opinion from Claude, run only for higher-scoring businesses, that can never change the two scores above. llmQualification.result.reasons[] explains its judgment in plain language.

See "Explaining the scores" and "LLM opportunity qualification" below for what the scores actually measure, and "Output" for the full field-by-field example.

Example workflow

A web design agency wants to find dentists in Amsterdam who might benefit from a website refresh. They run this Actor with:

{ "query": "dentists", "location": "Amsterdam", "maxResults": 20 }

For each business in the results, they look at:

  • opportunity.score and opportunity.tier — a quick, deterministic read of how many observable gaps were found.
  • opportunity.reasons[] — exactly which gaps were detected (e.g. no HTTPS, no contact form) and the evidence behind each one.
  • website.verificationReasons[] — why the analyzed website was (or wasn't) judged to actually belong to the business, so they don't waste time on a directory or social-media page mistaken for a site.
  • The underlying business/website facts — to sanity-check a result before spending any time on it.

They use this to build a shortlist of businesses worth a closer look — not a finished list of leads ready to contact.

What this product is / is not

This is:

  • A business discovery and website/digital-presence analysis tool.
  • A deterministic, rule-based opportunity-scoring system.
  • An optional, clearly-separated LLM qualification layer.
  • A structured research output for a human to review.

This is not:

  • A sales automation platform.
  • A CRM or contact-management system.
  • A guaranteed lead generator.
  • A buyer-intent or purchase-readiness database.
  • A full website crawler — analysis is homepage-only.
  • A guarantee that any business will purchase any service.

Explaining the scores

  • digitalPresenceScore (0–100) reflects how many observable digital-presence signals were found on a business's own, verified website (HTTPS, contact info, booking links, social links, and so on). It is a count of positive signals actually observed — not a measure of design quality, popularity, or revenue.
  • opportunityScore (0–100) reflects how many observable gaps were found — missing signals that could represent an opportunity for someone offering a related service. It is not a prediction of whether a business wants that service, will respond to outreach, or would ever buy anything.
  • Both scores are deterministic: the same input always produces the same score, using fixed rules this project defines and documents (see "Scoring model" below). They are not machine-learning predictions and not measures of buying intent or revenue potential.
  • Tiers (opportunity.tier: low 0–24, medium 25–49, high 50–100) are thresholds this project chose to group scores for convenience. They are project-defined ranges, not an industry standard — a low-tier result is not "bad" and a high-tier result is not a guaranteed opportunity.

Status: MCP interface available

In addition to the Apify Actor below, the same pipeline is also exposed as an MCP server with one tool, find_business_opportunities — see "MCP interface" further down for the (optional, secondary) details. The Actor itself is unchanged either way.

Actor status: production-hardened

The full pipeline is real end-to-end: discovery → website analysis → relevance verification → deterministic scoring → LLM qualification. The LLM is a qualitative layer on top of the deterministic score — it can never modify digitalPresenceScore/opportunityScore/websiteType. Phase 8 audited and hardened this pipeline for external Apify users: a per-business error boundary so one bad record can't lose an entire run, a synced Actor input schema, real bounded concurrency, and a few output fields that were computed internally but never actually surfaced.

Pipeline stages (technical)

  • Discovers real local businesses for a query/location via a reputable third-party Google Maps scraping Actor (we do not scrape Google Maps ourselves).
  • Fetches each business's candidate website (one HTTP GET, homepage only) and extracts facts: reachability, HTTPS, title/meta description, contact info, booking/appointment links, social links, and more.
  • Verifies whether that candidate URL is actually the business's own site versus a third-party directory or social/profile page, so evidence is never misattributed.
  • Computes two deterministic, reproducible scores from those facts — digitalPresenceScore and opportunityScore (with a tier and named reasons) — from fixed weights, no LLM involved.
  • Optionally sends businesses that clear a configurable opportunity-score threshold to an LLM (Anthropic Claude) for a qualitative, evidence-only judgment (qualified, opportunityType, confidence, summary, closed-vocabulary reasons) — a separate, clearly-labeled layer that can never change the deterministic numbers.
  • Produces one Dataset row per business with facts, deterministic scores, and (if run) the LLM judgment kept in distinct, never-conflated fields.

Explicit scope exclusions (technical)

  • It does not prove a business has no website when discovery returns none — websiteType: "not_provided" means "not found by this discovery source," never "confirmed absent."
  • It does not guarantee a detected opportunity reflects a real commercial need — both the deterministic score and the LLM judgment are evidence-based signals for a human to review, not a sales guarantee.
  • Website analysis is homepage-only and HTTP-only: no crawling, no JS execution. A JS-rendered single-page app's real content, or a booking widget on a different page, may not be seen. false/absent signals mean "not detected on the fetched page," not "confirmed absent from the whole site."
  • LLM qualification is probabilistic, not a fact extractor. It only reasons over the same evidence already computed deterministically, but its wording and judgment can vary between runs even on identical input (see "Known limitations" for an observed instance of residual interpretive language).
  • It is not a general-purpose Google Maps scraper — fields not relevant to identifying a digital opportunity (photos, opening hours, full review text, etc.) are intentionally not collected.
  • No MCP integration, no database, no dashboard/UI, no browser-based (Playwright) analysis, no competitor analysis, no billing/auth — out of scope for this Actor as currently built.

Website relevance verification

A discovery source can return a URL that is NOT the business's own website — observed directly in Phase 5's "Medical Center Gaffar" case, where the "website" field pointed to a third-party directory/citation page (nlzoommap.org) whose title was literally "NLmapNew.com — Local Business Directory in the Netherlands". Scoring that page's facts as if they were the business's own would have produced a misleading result.

verifyWebsiteRelevance (src/core/verification/websiteRelevanceVerifier.ts) runs after website analysis (it needs the fetched page's facts — final URL, title — so the fetch must happen first, even though some framings of this pipeline put verification "before" analysis) and classifies the candidate URL as one of:

  • business_website — likely the business's own site
  • third_party_directory — a listing/citation/aggregator page
  • social_or_profile — a social network or profile-based platform
  • unknown — genuinely ambiguous; NOT "probably not"
  • not_provided — no candidate URL existed to classify

This is explicitly not proof of legal ownership — just whether the page is appropriate evidence for judging the business's own digital presence. Confidence (high/medium/low) describes confidence in the classification, never business quality.

Classification logic, in priority order:

  1. Domain-list match always wins, even over a business-name match in the page content (src/config/knownWebsitePlatforms.ts, matched against the normalized registrable domain — see below, never a raw hostname substring). A directory or social page mentioning the business name is still a directory/social page — this is what makes third_party_directory/social_or_profile correctly fire even when the business name appears on the page. This check runs regardless of whether the fetch itself succeeded (a known social/directory domain that timed out is still classified by its domain).
  2. A generic directory-ish title (e.g. "business directory", "bedrijvengids") on a domain we haven't explicitly listed — a content-based fallback.
  3. Otherwise, accumulate weak identity evidence: PAGE_TITLE_MATCH (+40), BUSINESS_NAME_MATCH in the meta description (+20), PHONE_MATCH (+35, comparing the last 9 digits so country-code/formatting differences don't block a match), ADDRESS_MATCH (+25, Dutch postal code), REDIRECTED_TO_BUSINESS_DOMAIN (+10) or FINAL_DOMAIN_MATCH (+5, same domain throughout). Score ≥60 → business_website/high; ≥30 → business_website/medium; otherwise → unknown/low. A BUSINESS_NAME_MISMATCH reason is added (not a rejection) when title/ meta description exist but share no tokens with the business name.

Domain normalization: the psl package (public-suffix-list based) extracts the true registrable domain (www.example.co.ukexample.co.uk), so notfacebook.com and facebook.example.com are never confused with facebook.com — verified directly in tests. Domain-list configuration is intentionally small and NL/local-business-focused (Dutch directories like cylex.nl, goudengids.nl; social platforms like Facebook, Instagram, Yelp, Tripadvisor), not an exhaustive global blacklist — extend it as new false positives are observed.

Identity evidence (name/phone/address matching) required extending WebsiteSignals with detectedPhoneNumbers/detectedPostalCodes — the actual matched substrings, not just the Phase 4 booleans — since comparing against business.phone/business.address needs the real value.

Scoring model

Two intentionally separate, deterministic scores — never derived from each other (opportunityScore is never 100 - digitalPresenceScore). All weights live in src/config/scoringWeights.ts, documented inline; nothing scoring-related is hardcoded elsewhere.

Phase 6 change: content-based facts (HTTPS, title, contact form/page, booking, social links) are only ever scored as evidence of the business's OWN digital presence when website.websiteType === "business_website". A third_party_directory/social_or_profile/unknown classification gets a fixed, small neutral digitalPresenceScore of 15 instead — high enough to reflect "they clearly have some web presence" but far below what a verified site's actual facts could earn, and low enough to never imply we assessed their own site's quality. This applies even if the fetch itself failed (a known social/directory domain that timed out still gets the neutral 15, via the domain classification alone).

digitalPresenceScore (0–100) — purely additive from POSITIVE, directly-observed facts on a verified business website (reachable, HTTPS, title, meta description, viewport, phone/email/address, contact page/form, social links, booking capability), capped at 100. There are no separate penalty weights: a fact that wasn't observed simply contributes nothing, which already produces a low score without subtracting anything — this is how double-counting is avoided by construction. When the website couldn't be meaningfully analyzed (unreachable/timeout/invalid URL/non-HTML/too large), wasn't found in discovery at all, or wasn't verified as the business's own site, the score is a single small fixed value for that category (0 for "not found", 5 for a confirmed technical failure, 10 for "responded but unanalyzable", 15 for "unverified/ third-party") — never a sum of "missing metadata" penalties for a page we never actually got to see, or shouldn't be attributing to this business.

opportunityScore (0–100) — additive from structured, evidence-only reasons ({code, weight, evidence}), in three mutually-exclusive groups:

  1. Website availability (at most one fires): WEBSITE_UNREACHABLE (40 — the most confirmed problem: a specific URL was tried and failed), WEBSITE_NOT_FOUND_IN_DISCOVERY (35 — deliberately below unreachable, since a missing URL in the discovery source is the least certain of these signals and never implies the business has no website anywhere), WEBSITE_TIMEOUT (30), WEBSITE_INVALID_URL (20), WEBSITE_NON_HTML_OR_UNANALYZABLE (12).
  2. Unverified website identity: THIRD_PARTY_URL_ONLY (10 — deliberately small; the only evidence found was a directory/social page, not a verified business-owned site. Never inflated into a large score merely because a directory page was discovered — see "Avoid turning uncertainty into a sales lead" below. When websiteType is "unknown" with a successfully analyzed page, no reason fires at all — genuinely ambiguous evidence contributes nothing either way.)
  3. Content-based (only when websiteType === "business_website" AND analysisStatus === "success"): NO_BOOKING_OR_APPOINTMENT_DETECTED (20), HTTP_ONLY_NO_HTTPS (12), NO_CONTACT_FORM_DETECTED (8), NO_CONTACT_INFO_DETECTED (8 — one consolidated reason for phone+email+address all missing, not three), NO_CONTACT_PAGE_DETECTED (6), NO_SOCIAL_LINKS_DETECTED (5), MISSING_META_DESCRIPTION (5), MISSING_VIEWPORT_META (5), WEAK_OVERALL_WEBSITE_PRESENCE (10, when digitalPresenceScore < 30).

Rating and review count are never used as scoring inputs — verified in both unit tests and the real integration test (identical website evidence with a 2.1★/3-review business and a 4.9★/900-review business produces the exact same opportunityScore). They're passed through as context in business.googleRating/googleReviewCount for a human or a future LLM to weigh, per the explicit instruction not to invent a relationship between popularity and digital weakness.

Tiers (opportunity.tier): low 0–24, medium 25–49, high 50–100. Chosen around evidence convergence rather than raw severity: a single strong standalone signal (e.g. websiteUnreachable alone = 40) lands in medium — serious, but one data point — while high requires either the single most-confirmed signal or multiple converging content gaps. These are our model's ranges, not an objective severity scale.

LLM opportunity qualification

In plain terms: LLM qualification is optional and only runs for businesses whose deterministic opportunityScore already clears a threshold — deterministic scoring always happens first, for every business, whether or not the LLM ever runs. The LLM cannot override, adjust, or replace any deterministic score; it can only add a separate, clearly-labeled qualitative opinion (llmQualification) reasoned over the same evidence already computed deterministically — never over raw website content, and never disclosed here as internal prompt text. Its output is validated against a fixed structure before being trusted. If the LLM call fails or times out, that business is not penalized or treated as a negative result — its deterministic scores stand unaffected, and llmQualification.status simply records "failed" or "not_run".

Answers a different question than the deterministic score: not "how many observable opportunity signals exist" (already computed) but "given this evidence, does this look like a credible opportunity worth a human looking into?" The LLM is a qualitative layer, never the source of truth for facts, and can never modify digitalPresenceScore/opportunityScore/ opportunityTier/reason codes/websiteType — those are already final by the time qualification runs (runPipeline: discovery → analysis → verification → scoringqualification).

Evidence bundle (buildOpportunityEvidence, core/ports/llmProvider.ts OpportunityEvidence): a compact, deterministic JSON object — business identity (name/category/address/rating/reviewCount/phone), verified website facts (candidateUrl/finalUrl/websiteType/verificationConfidence/ title/metaDescription/https/reachable/statusCode/language), the ten boolean signals, and the deterministic digitalPresenceScore/ opportunityScore/tier/reasons. Never raw HTML, never a URL to fetch, never unstructured discovery metadata (e.g. googleMapsUrl is excluded). Same inputs always produce the same bundle.

Output schema (LLMQualificationResultSchema, core/entities/llmQualification.ts): qualified: boolean, opportunityType (one of website/booking/conversion/contact/ mobile/digital_presence/unknown — the LLM's chosen primary category, not one per deterministic reason), confidence (high/ medium/low — confidence in the classification, never business quality), summary (≤500 chars), reasons (≤5 {code, evidence} entries). Enforced via Anthropic's native structured-output support (output_config.format: {type: "json_schema", ...}), then re-validated with the same Zod schema — never trusted blindly. Phase 7.1: reasons[].code is constrained to the closed OPPORTUNITY_REASON_CODES vocabulary (core/entities/opportunityReasonCodes.ts — the same single source of truth the deterministic scoring service itself uses) plus one OTHER fallback, enforced at the JSON-Schema level (the model cannot even generate a code outside this enum), not just via Zod afterward — see "Reason-code vocabulary" below.

Status envelope (LLMQualificationSchema):

{status: "not_run" | "success" | "failed", result, notRunReason, failureReason, failureDetail, modelUsed, usage}
. All three states are structurally distinct — a missing qualification is never just null. failureReason is one of timeout / provider_error / invalid_output / configuration_error. usage ({inputTokens, outputTokens}, Phase 7.1) holds real token counts from the Anthropic API response, summed across the initial attempt and the retry if one happened — populated only on "success", and never an estimate.

Prompt (src/config/llmQualificationPrompt.ts, versioned): explicitly instructs the model that it is a qualification layer and not a fact extractor, to use only supplied evidence, never infer missing information as negative (websiteType: "unknown" → "could not be verified", never "poor website"; no candidate URL → "no website URL was returned by the discovery source", never "no website"), never modify the deterministic scores, treat a third_party_directory/social_or_profile page as NOT the business's own site, describe a business-name mismatch neutrally ("appears associated with a differently named practice") without calling it fraud/a rebrand/an error, and never invent an opportunity from rating, review count, category, location, domain age, tech stack, or an analyzer failure. v2 (Phase 7.1) additionally forbids: generic claims about what a business "needs", speculation about customer behavior, assuming a feature matters merely because it's common in an industry, and unsupported sales/marketing advice — added after real, observed drift in Phase 7's live integration test (see "Known limitations" below).

Reason-code vocabulary (Phase 7.1): before this, the LLM would occasionally invent its own reason codes (e.g. DIGITAL_PRESENCE_SCORE_ZERO) instead of reusing the deterministic ones. Fixed at three layers: (1) the Anthropic structured-output schema's enum on reasons[].code — the model structurally cannot output a code outside the list; (2) Zod (LLMQualificationReasonSchema/OpportunityReasonSchema both import the same OpportunityReasonCodeSchema/LLMReasonCodeSchema) as defense in depth; (3) the prompt explicitly lists every allowed code (generated from OPPORTUNITY_REASON_CODES at module load, so it can never drift out of sync) and states the OTHER fallback to use instead of inventing one.

Gate (cost control): the LLM is only called when opportunityScore >= llmQualificationThreshold (Actor input, default 25) — deliberately the same low/medium tier boundary used for opportunity.tier, so the gate and the tier system tell a consistent story: below it, deterministic evidence is too thin for a qualitative pass to add anything; from "medium" up, there's at least one substantial signal (or several converging weak ones) worth a second look. A verified, feature-complete business_website naturally never crosses the gate.

Retry: at most 1 retry, and only for malformed/invalid output (bad JSON, or JSON that fails the Zod schema) — the retry resends the conversation with a corrective note. Transport-level failures (timeout, rate limit, auth, server error) are classified and thrown immediately, never retried. One real, live-discovered constraint: Anthropic's structured-output JSON Schema support rejects maxItems on array types (400: property 'maxItems' is not supported) — fixed by dropping that keyword from the API-level schema and relying on the Zod schema (.max(5)) downstream for that constraint instead.

Provider: AnthropicLLMProvider (src/providers/llm/) is the only concrete LLMProvider implementation; the port itself has zero Anthropic dependency, so a different vendor could be swapped in without touching core/. Model/temperature/timeout/max-output-tokens are all Actor input fields, never hardcoded. A BadRequestError specifically about temperature (some newer models reject it — see the SDK's own deprecation note) triggers one transparent retry without that parameter. The API key is read from ANTHROPIC_API_KEY (.env-only, never logged); if it's unset, the Actor skips wiring the provider entirely and reports qualification as not-implemented at the pipeline level, rather than failing every qualifying business individually.

Website analysis: how it works

One GET request per website, HTTP-only — no Playwright, no JS execution.

  • Facts vs. signals vs. status are three separate concepts: website.analysisStatus (success / not_provided / invalid_url / unreachable / timeout / non_html_response / response_too_large / error) says what happened when analysis was attempted; website.* holds directly observed connectivity/metadata facts (status code, final URL after redirects, HTTPS, content type, response time, title, meta description, canonical URL, language); signals.* holds deterministic pattern matches (booking/appointment links, contact info, social links). When analysisStatus isn't "success", every signal is null — absence of evidence is never presented as evidence of absence. A business with no website URL at all gets "not_provided", not an error.
  • Conservative-by-design heuristics to avoid false positives: hasBookingLink/hasAppointmentLink only match a term (English: book, booking, appointment; Dutch: boek, reserveren, afspraak) in an anchor's text or href — never in body prose, so an article that merely mentions "appointment" doesn't count. hasOnlineBooking is a separate, stronger signal requiring a link to a known third-party booking platform (Calendly, Doctolib, Treatwell, SimplyBook, Booksy, ...). Phone/email detection prefers authoritative tel:/mailto: links over body-text regex matching, strips <script>/<style> content first, and rejects known placeholder email domains (e.g. wixpress.com, example.com). hasAddress uses the Dutch postal-code pattern (1015 CW) as a low-false-positive proxy — it won't detect non-NL address formats.
  • Real observed tradeoff: in the Phase 4 integration test, two real dental sites had "Maak snel een afspraak!" ("book an appointment quickly!") in their <meta name="description">, yet correctly scored hasAppointmentLink: false because that phrase never appeared in an anchor on the fetched page. This is deliberate: we accept some false negatives to avoid false positives from prose/meta text.
  • Scope: only the single fetched page (typically the homepage) is inspected — no crawling. false means "not detected on that page", not "confirmed absent from the whole website".
  • Safety: an 8s timeout per request; a 2MB response-size cap (checked via Content-Length first, with a post-fetch fallback check); DNS/TLS/ connection failures, timeouts, non-HTML responses, and oversized responses all return a structured result — they never throw and never stop the pipeline from processing other businesses.

Provider selected: Google Maps discovery

We do not scrape Google Maps ourselves — that would duplicate a mature, widely-used Apify Actor for no product benefit (see architecture Phase 1).

  • Actor ID: compass/crawler-google-places (Apify Store: "Google Maps Scraper", also listed as apify/google-maps-scraper — same Actor, same maintainer). ~426k users, 4.8★, priced from $1.50 / 1,000 scraped places. Configurable via the GOOGLE_MAPS_ACTOR_ID env var if it ever needs to change.
  • Input we send (verified against the Actor's own input schema): searchStringsArray: [query], locationQuery: location, maxCrawledPlacesPerSearch: <bounded fetch limit>.
  • Output fields we consume (everything else is ignored): title, address, phone/phoneUnformatted, categoryName, totalScore, reviewsCount, website, url, placeId.
  • Fetch sizing: we request min(maxResults × 3, 200) raw places in a single call (no retry/pagination loop) to absorb duplicates/invalid records while keeping cost bounded regardless of maxResults.
  • Deduplication: by placeId first, then normalized Google Maps URL, then normalized name+address — exact-match only, no fuzzy matching.
  • Cross-actor results are read via the Apify API client (Actor.apifyClient.dataset(...).listItems()), not Actor.openDataset() — the latter only resolves local/this-run storage and cannot see another Actor run's remote dataset.

Project structure

src/
core/ # Apify-agnostic domain logic
entities/ # Business, WebsiteFacts, WebsiteSignals, DigitalPresence, OpportunityAssessment, ResultItem
schemas/ # Zod schemas for the Actor input and Dataset output boundaries
ports/ # Interfaces for future providers (Discovery, WebsiteAnalyzer, LLM)
pipeline/ # Orchestration (runPipeline) and per-stage stubs
scoring/ # opportunityScoringService — pure, deterministic, no I/O
verification/ # verifyWebsiteRelevance, registrableDomain, identityMatching — pure, deterministic, no I/O
config/
scoringWeights.ts # every scoring weight/threshold, documented, in one place
knownWebsitePlatforms.ts # known directory/social domains + directory-title keywords, documented, in one place
providers/
discovery/ # GoogleMapsApifyProvider + raw-record mapping/dedup/normalization
website/ # HttpWebsiteAnalyzer + URL normalization + HTML signal extraction
adapters/
apify/ # Actor entrypoint, input/output mapping
utils/
logger.ts # Thin wrapper around Apify's logger
tests/
unit/
fixtures/
.actor/ # actor.json, input_schema.json, Dockerfile

opportunityScoringService.ts lives under core/ rather than the services/ path suggested in the Phase 5 brief: it has zero I/O dependencies (no Apify, HTTP, Cheerio, or LLM), which is exactly what core/ already guarantees, and placing it there avoids core/pipeline depending on a services/ layer that architecturally sits above core (services → core, never the reverse). services/ remains uncreated — there's still no orchestration logic that needs it.

services/ and config/ are intentionally not created yet — there is no scoring/LLM logic to put in them until later phases.

Input

{
"query": "dentists",
"location": "Amsterdam, Netherlands",
"maxResults": 10,
"analysisDepth": "standard",
"maxWebsiteConcurrency": 3,
"llmModel": "claude-haiku-4-5-20251001",
"llmTemperature": 0,
"llmTimeoutMs": 20000,
"llmMaxOutputTokens": 1024,
"llmQualificationThreshold": 25
}

Only query and location are required (minimum 2 characters each); every other field has a sensible default. maxResults is capped at 200 — the discovery provider itself never fetches more than 200 raw places in a single run regardless of a higher value, so a higher cap would let a user request a number of results the system can structurally never deliver. analysisDepth is accepted and validated but currently has no effect (reserved for a possible future deeper/browser-based analysis mode — see "Known limitations"). See .actor/input_schema.json for the full field descriptions and defaults as they appear in the Apify Console UI.

This maxResults cap of 200 is specific to the Apify Actor. The optional MCP interface (see "MCP interface" below) exposes a different, independent maxResults cap of 35 and a few additional filter fields — those MCP-only fields (minOpportunityScore, opportunityTypes, deterministicReasonCodes, requireBusinessWebsite, minimumConfidence) do not exist in the Actor's input schema and cannot be passed to the Actor.

Output

Each Dataset row conforms to the ResultItem schema (src/core/entities/resultItem.ts): one business plus everything observed and inferred about it, kept in clearly separated objects.

{
"business": {
"name": "Amstel Dental",
"address": "Wibautstraat 172, 1091 GR Amsterdam, Netherlands",
"phone": "+31 20 723 5353",
"category": "Dentist",
"googleRating": 4.3,
"googleReviewCount": 319,
"websiteUrl": "https://amsteldental.nl/tandarts/amsterdam",
"googleMapsUrl": "https://www.google.com/maps/search/?api=1&query=..."
},
"website": {
"analysisStatus": "success",
"reachable": true,
"httpsEnabled": true,
"statusCode": 200,
"finalUrl": "https://amsteldental.nl/tandarts/amsterdam",
"title": "Amstel Dental — Tandarts Amsterdam",
"metaDescription": "...",
"language": "nl",
"candidateUrl": "https://amsteldental.nl/tandarts/amsterdam",
"websiteType": "business_website",
"verificationConfidence": "high",
"verificationReasons": [
{ "code": "PAGE_TITLE_MATCH", "evidence": "The business name appears in the analyzed page's title." }
]
},
"signals": {
"hasContactForm": true,
"hasBookingLink": false,
"hasPhoneNumber": true,
"hasEmailAddress": true,
"hasAddress": true,
"hasSocialLinks": true,
"socialLinks": ["https://facebook.com/amsteldental"]
},
"digitalPresence": {
"score": 70,
"method": "deterministic_weighted_v1",
"breakdown": { "httpsEnabled": 10, "hasContactForm": 15 }
},
"opportunity": {
"score": 33,
"tier": "medium",
"method": "deterministic_weighted_v1",
"reasons": [
{ "code": "NO_BOOKING_OR_APPOINTMENT_DETECTED", "weight": 20, "evidence": "No booking- or appointment-related link was detected on the analyzed page." }
]
},
"llmQualification": {
"status": "success",
"result": {
"qualified": true,
"opportunityType": "booking",
"confidence": "medium",
"summary": "Verified business website with strong contact presence, but no online booking or appointment scheduling was detected.",
"reasons": [{ "code": "NO_BOOKING_OR_APPOINTMENT_DETECTED", "evidence": "No booking- or appointment-related link was detected on the analyzed page." }]
},
"notRunReason": null,
"failureReason": null,
"failureDetail": null,
"modelUsed": "claude-haiku-4-5-20251001",
"usage": { "inputTokens": 2896, "outputTokens": 188 },
"promptVersion": "v2"
},
"evidence": [],
"meta": { "pipelineStagesCompleted": ["discovery", "websiteAnalysis", "verification", "scoring", "qualification"], "errors": [] }
}

(Fields are illustrative and trimmed for readability — see src/core/entities/resultItem.ts and its sub-schemas for the complete, authoritative field list.)

business/website/signals are facts; digitalPresence/opportunity are deterministic inferences; llmQualification is a separate, optional qualitative judgment that never modifies the two objects before it. A null field always means "not determined yet", never "determined to be false/absent" — for example, hasViewportMeta records only that a <meta name="viewport"> tag was seen, and is not by itself evidence of good mobile usability. When website.analysisStatus isn't "success", every entry in signals is null rather than false — absence of evidence is never presented as evidence of absence. llmQualification.status is one of "not_run" (gated out or no provider configured — not a failure), "success", or "failed" (the LLM call itself failed; the deterministic opportunity/digitalPresence on the same row remain fully valid regardless). The top-level evidence array is always [] in the current implementation — it is not yet populated by any pipeline stage; use opportunity.reasons[], website.verificationReasons[], and llmQualification.result.reasons[] for the actual evidence behind a row.

Reliability and partial failures

  • One business's failure never loses the whole run. Each business is processed through its own error boundary (processBusiness in runPipeline.ts); if a pure-function step throws unexpectedly for one business, that business's row is still produced (with the failure recorded in meta.errors and website.analysisStatus: "error") and every other business is unaffected. This is deliberately not "hope nothing throws" — it's a runtime guarantee, covered by a dedicated test.
  • Named failure modes are handled explicitly, not just caught generically: discovery provider failure, malformed discovery records, invalid/missing website URL, connection timeout, non-HTML response, oversized response, 4xx/5xx status codes, website-verification/scoring errors, and LLM timeout/rate-limit/invalid-output/configuration errors each map to a specific, honestly-reported status rather than a generic "failed" or a silently-wrong result.
  • Output validation is resilient, not all-or-nothing: each result row is validated independently before being pushed to the Dataset (validateResultItems); a single malformed row is dropped and logged (and counted in OUTPUT.invalidResultCount) rather than discarding every valid row in the same run.
  • Website analyses run with bounded concurrency (maxWebsiteConcurrency, default 3, max 10) via a small dependency-free worker pool (mapWithConcurrency) — enough to avoid fully sequential throughput on runs with many businesses, without unbounded concurrent requests to third-party sites.
  • LLM qualification failures are isolated: a timeout, rate limit, or malformed-output failure for one business's LLM call never blocks or invalidates other businesses, and never invalidates that business's own already-computed deterministic score.

Cost behavior

  • Discovery (Google Maps provider) has a real, billed cost — this Actor calls a third-party Apify Actor priced per successfully-scraped place (pay-per-event pricing; see "Provider selected" above). We deliberately do not hardcode a per-result price here: the upstream Actor's own pricing can change, and restating an uncertain third-party number would go stale silently. Check the discovery Actor's current pricing on the Apify Store before relying on a cost estimate. Fetch size itself is bounded (min(maxResults × 3, 200) raw places in one call, no retry/pagination loop), so a larger maxResults can increase discovery cost — but the exact cost cannot be inferred from maxResults alone. Real validation runs have repeatedly shown that the number of places the upstream Actor actually bills as "successfully scraped" is not the same as the number of places requested or returned in its dataset, and this billed count has varied materially between otherwise-identical runs (observed real Apify charges have ranged from under a cent to over $0.30 depending on maxResults, query, and location, with no simple linear relationship established). Treat any specific dollar figure as illustrative of past behavior, not a guarantee for a future run. The per-place price itself also depends on the calling Apify account's own usage tier for this discovery Actor (Apify publishes several tiers with a lower per-place price at higher usage volumes) — the figures above reflect this project's own (lowest, entry-level) tier during testing, so a Store customer on a different tier may see a different effective price for the same request. As a mathematical (not typical-case) ceiling: since fetch size is capped at min(maxResults × 3, 200) raw places, discovery cost for a single run cannot exceed that fetch size billed in full at the caller's own per-place rate, plus the fixed per-run charge — actual cost has, in every real run observed so far, been at or below this ceiling, and often well below it.
  • Website HTTP analysis has no external API cost — it's a direct HTTP GET per candidate website, bounded by an 8s timeout and a 2MB response cap per request. Only Apify's own compute-unit billing for the Actor run itself applies here, same as any Apify Actor.
  • LLM qualification is cost-gated: only businesses whose opportunityScore clears llmQualificationThreshold (default 25) are sent to the LLM — a verified, feature-complete website naturally never crosses the gate. At most one retry per business, only for malformed output. Real token usage (llmQualification.usage.inputTokens/ outputTokens) is captured from the actual Anthropic API response and written to every successful row — but no dollar-cost figure is computed, since that would require hardcoding per-model Anthropic pricing this codebase has no way to keep current. Convert usage to a dollar cost yourself using Anthropic's current published pricing for whichever llmModel you configured.

Environment variables / secrets

The Actor needs an Apify API token to call the Google Maps discovery Actor (see "Provider selected" above) and, optionally, an Anthropic API key to run LLM qualification. Secrets are never hardcoded or committed:

  • .env.example documents both variables (APIFY_TOKEN=, ANTHROPIC_API_KEY=).
  • Copy it to .env and fill in your keys; .env is git-ignored, so it can never be committed.
  • Locally, dotenv loads .env automatically (src/adapters/apify/main.ts imports dotenv/config first). On the Apify platform itself, both variables are provided as Actor secrets/environment variables — .env is not deployed (it's git-ignored and not copied into the Docker image), so nothing needs to change for cloud runs.
  • If APIFY_TOKEN is missing when discovery runs, the Actor fails immediately with a clear, actionable message.
  • ANTHROPIC_API_KEY is optional: if it's unset, the Actor skips wiring the LLM provider entirely and reports qualification as not-implemented (discovery/analysis/verification/scoring still run normally) — it does not fail the run.
  • Neither key is ever logged, printed, or included in any error message — verified directly in the Phase 7 real integration test (grepped run logs and Dataset output for both secret values afterward).

Where to get an Apify token: sign in to the Apify Console, go to Settings → Integrations → API tokens (directly: https://console.apify.com/settings/integrations).

Where to get an Anthropic API key: sign in to the Anthropic Console, go to Settings → API keys (directly: https://console.anthropic.com/settings/keys).

APIFY_TOKEN=apify_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxx

Running locally

Requires Node.js 18+.

npm install
cp .env.example .env # then edit .env and add your APIFY_TOKEN
npm run build
mkdir -p storage/key_value_stores/default
cat > storage/key_value_stores/default/INPUT.json << 'EOF'
{
"query": "dentists",
"location": "Amsterdam, Netherlands",
"maxResults": 10
}
EOF
node dist/adapters/apify/main.js

The run logs which pipeline stages are not implemented yet and writes a status summary to storage/key_value_stores/default/OUTPUT.json. Discovery calls a real, billed third-party Apify Actor — keep maxResults small while testing.

Alternatively, with the Apify CLI installed:

$apify run

Running tests

npm test # run once
npm run test:watch
npm run typecheck

Tests run fully offline — no network access or Apify credentials required.

MCP interface

In addition to the Apify Actor, the same pipeline is exposed as an MCP (Model Context Protocol) server with exactly one tool: find_business_opportunities. It reuses the existing discovery/analysis/ verification/scoring/qualification pipeline unmodified — the MCP layer (src/adapters/mcp/) only translates input/output and applies filtering; it never re-implements or duplicates any of that logic. See docs/mcp-interface-design.md for the full design rationale.

Tool input (all fields optional except query/location):

FieldTypeDefaultNotes
querystringrequired, e.g. "dentists"
locationstringrequired, e.g. "Amsterdam, Netherlands"
maxResultsnumber20capped at 35 (a tighter, independent ceiling than the Actor's own 200 — an MCP response must fit an agent's context window, and this value reflects real deployed-platform request/session limits, not just context-window sizing)
minOpportunityScorenumber0deterministic filter on opportunity.score
deterministicReasonCodesstring[]nonedeterministic filter on opportunity.reasons[].code
requireBusinessWebsitebooleanfalsedeterministic filter on verified website ownership
opportunityTypesstring[]noneLLM-dependent filter — requires a successful AI qualification
minimumConfidence"low"|"medium"|"high"noneLLM-dependent filter — requires a successful AI qualification

Deterministic filters vs. LLM-dependent filters — this distinction is the most important behavioral guarantee of the MCP tool, corrected in Phase 9.1 after an initial design coupled them incorrectly:

  • minOpportunityScore, deterministicReasonCodes, and requireBusinessWebsite are answered entirely by the deterministic score/reasons/website-verification already computed by the pipeline — they never trigger an LLM (Anthropic) call, no matter what value is supplied.
  • opportunityTypes and minimumConfidence require the optional AI qualification step to have succeeded for a candidate. When either is present in a request, the AI qualification step runs, but only for businesses that already survived every deterministic filter first — never for the full discovered set. A candidate whose qualification failed or was never run cannot satisfy either filter (this is reported via meta.warnings/meta.businessesWithErrors, never as a false "does not match").

Every returned opportunity keeps facts (business/website/signals), deterministic inference (digitalPresence/opportunity), and the optional AI qualification (llmQualification) in separate objects — the AI layer can never change a deterministic score.

Environment variables: identical to the Actor above — APIFY_TOKEN (required; the MCP server calls the same third-party Google Maps discovery Actor) and ANTHROPIC_API_KEY (optional; when unset, every request still succeeds, with opportunityTypes/minimumConfidence matching nothing and meta.llmQualificationAvailable: false). Neither key is ever exposed through tool input, tool output, or logs.

Running locally (stdio transport):

npm install
cp .env.example .env # add APIFY_TOKEN and (optionally) ANTHROPIC_API_KEY
npm run build
node dist/adapters/mcp/server.js

The process communicates over stdio using the standard MCP protocol — point an MCP client (e.g. Claude Desktop, or the official SDK's Client class) at node dist/adapters/mcp/server.js with this project's directory as its working directory.

Deploying to MCPize (Phase 11 — deployment prepared, not yet published to any marketplace): this project includes mcpize.yaml, declaring runtime: typescript, the MCP entry point (src/adapters/mcp/server.ts), the build commands above,

startCommand. type: stdio
(MCPize automatically bridges stdio to HTTP for hosting — the transport itself is unchanged), and the two secrets above as publisher-provided infrastructure credentials (APIFY_TOKEN required, ANTHROPIC_API_KEY optional). To deploy:

npx mcpize login # interactive browser login — must be run by a human
npx mcpize secrets set APIFY_TOKEN
npx mcpize secrets set ANTHROPIC_API_KEY # optional
npx mcpize doctor # pre-deploy validation
npx mcpize deploy

mcpize deploy does not list the server on any marketplace or enable monetization — those are separate, explicit steps (mcpize publish) not performed by this project. To test a deployed server, connect an MCP client to the deployment's endpoint the same way as the local instructions above, or use npx mcpize run / the MCPize Playground.

Known limitations

  • maxWebsiteConcurrency is real and enforced (Phase 8: previously accepted/validated but silently unused — website analyses ran fully sequentially regardless of this setting; now actually bounds concurrent website fetches via a small worker pool). Default 3, max 10 — fine for maxResults in the tens to low hundreds; this Actor does not attempt more aggressive optimization (e.g. dynamic concurrency tuning) since the current bound has not been observed to be a bottleneck at the supported maxResults range.
  • Fixed in 7.1: the LLM inventing its own reason codes (DIGITAL_PRESENCE_SCORE_ZERO, DIGITAL_PRESENCE_INCOMPLETE, ...) instead of reusing the deterministic vocabulary. reasons[].code is now constrained to OPPORTUNITY_REASON_CODES (core/entities/opportunityReasonCodes.ts, the single source of truth also used by the deterministic scoring service) plus one OTHER fallback — enforced at THREE layers: the Anthropic structured-output JSON Schema (enum on reasons[].code, so the model cannot even generate an invented code), the Zod LLMQualificationResultSchema (defense in depth), and the prompt (explicit closed list + "do not invent a new one"). Verified live: 3/3 successful qualifications in the Phase 7.1 real integration test reused only real codes.
  • Substantially reduced, not fully eliminated: the LLM occasionally still adds a mild interpretive/generic gloss beyond the evidence despite the tightened prompt (v2) explicitly forbidding it — observed live in the Phase 7.1 test: "...this is a significant gap in patient convenience" and "...reduces search engine visibility and click-through rates" attached to otherwise well-grounded reasons. Two of three live responses this run were fully clean; one had this residual drift. This is an inherent limit of prompt-based guardrails, not a code defect — expect a small residual rate of this kind of language regardless of further prompt tightening.
  • Token usage is now captured (llmQualification.usage.inputTokens/ outputTokens, real figures from the Anthropic API response, summed across the initial attempt and the retry if one happened) — but no dollar-cost estimate is computed. Doing so would require hardcoding per-model pricing this codebase has no way to verify is current for these model names; per the Phase 7.1 brief's own guidance, that's worse than not reporting a number, so it was deliberately left out. Convert usage to cost yourself using current published Anthropic pricing if needed.
  • Anthropic's structured-output JSON Schema is a constrained subset (no maxItems on arrays, discovered live) — array/string length limits are enforced only via the Zod schema and prompt wording, not the API-level schema. Watch for other unsupported keywords if the output schema grows.
  • The LLM evidence bundle includes third-party content — specifically the analyzed website's title and meta description, scraped directly from a business's (or a third party's) own site. This is content the system does not control, and a website operator could in principle include text attempting to influence the LLM's qualification (a form of indirect prompt injection). The system limits what reaches the LLM (no raw HTML, no full page content — only these two short, already-parsed fields alongside the rest of the compact evidence bundle), and the LLM's output is constrained by native structured output, Zod validation, a closed opportunityType/reason-code vocabulary, and a summary length limit — the LLM cannot return arbitrary text or modify digitalPresenceScore/opportunityScore, which are already final by the time it runs. This does not make the system immune to this class of risk. Treat llmQualification as an additional, qualitative signal to review — not as an authoritative fact — the same way the rest of this document already treats it relative to the deterministic score.
  • Identity-matching heuristics are conservative, deterministic, and language-naive — a business name in one language won't textually match a page title in another (e.g. an English Google-Maps-listed name vs. a Dutch homepage title). Phone-number matching mitigates this somewhat (language-independent), but a legitimate business website can still land in unknown rather than confidently business_website when no corroborating evidence is available. This is the intended fail-safe/cautious behavior — "unknown" is a safe default, never a false rejection dressed up as confidence.
  • verifyWebsiteRelevance's known-domain lists (src/config/knownWebsitePlatforms.ts) are intentionally small and NL/local-business-focused, not an exhaustive global blacklist — a directory/social platform not yet on the list will fall through to identity-based classification instead (usually landing in unknown, rarely a false business_website, since domain-list matching only ever narrows scope, never expands it).
  • Discovery relevance is only as good as the upstream Actor's Google Maps search: off-topic results (e.g. a trade association showing up for a "dentists" query) have been observed live in every Phase 3–6 integration run so far — verification does not attempt to judge query relevance, only whether a URL is good evidence of a business's own site.
  • No retry/pagination loop if a single discovery call doesn't yield enough valid, deduplicated businesses to satisfy maxResults — a documented, deliberate simplification, not a bug.
  • Google Maps itself has an approximate ~120-result display limit for a single search area; the discovery Actor's documented approach to exceeding this is geographic subdivision (splitting a large search area into smaller sub-areas and combining results). This project's own fetch size is capped at 200 raw places regardless of maxResults (see "Fetch sizing" above), which can fall on either side of that ~120 threshold depending on maxResults. Behavior specifically in the subdivision regime (larger requests, or geographically broad locations) has not been comprehensively validated by this project — results should not be assumed complete or the subdivision behavior assumed to always succeed for every query/location combination.
  • Website analysis is homepage-only (a single request) and HTTP-only: no JS execution, so a JS-rendered SPA's real content won't be seen. No broken-link checking, tech-stack detection, or ordering-link detection yet (brokenLinksSample/techSignals stay empty, hasOrderingLink stays null) — deliberately deferred, not silently broken.
  • Heuristics favor precision over recall (see "Website analysis" above): some real booking/appointment mechanisms will be missed if they don't surface as a matching link on the homepage itself.
  • hasAddress only recognizes Dutch postal codes; no other country's address format is detected.
  • Scoring weights (src/config/scoringWeights.ts) are an initial, documented judgment call, not a calibrated/validated model — see README "Scoring model" and the Phase 5/6 reports for the reasoning behind each value. Expect to retune these once more real data is reviewed.
  • services/ still does not exist — opportunityScoringService.ts and websiteRelevanceVerifier.ts live under core/ instead (see "Project structure" above for why).
  • No ESLint configuration yet.