Business Opportunity Finder
Under maintenancePricing
$10.00 / 1,000 business opportunity founds
Business Opportunity Finder
Under maintenanceFind 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
Maintained by CommunityActor 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
- Finds local businesses matching a search query and location, via a third-party Google Maps discovery Actor.
- Analyzes each business's candidate website — one HTTP request, homepage only — for signals like HTTPS, contact info, booking links, and social presence.
- Verifies whether that website actually belongs to the business, rather than being a directory or social-media page mistaken for one.
- Calculates two deterministic, rule-based scores from the observed facts: a digital-presence score and an opportunity score.
- 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.
- 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, andwebsite.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.scoreandopportunity.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/websitefacts — 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:low0–24,medium25–49,high50–100) are thresholds this project chose to group scores for convenience. They are project-defined ranges, not an industry standard — alow-tier result is not "bad" and ahigh-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/locationvia 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 —
digitalPresenceScoreandopportunityScore(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-vocabularyreasons) — 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 sitethird_party_directory— a listing/citation/aggregator pagesocial_or_profile— a social network or profile-based platformunknown— 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:
- 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 makesthird_party_directory/social_or_profilecorrectly 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). - A generic directory-ish title (e.g. "business directory", "bedrijvengids") on a domain we haven't explicitly listed — a content-based fallback.
- Otherwise, accumulate weak identity evidence:
PAGE_TITLE_MATCH(+40),BUSINESS_NAME_MATCHin 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) orFINAL_DOMAIN_MATCH(+5, same domain throughout). Score ≥60 →business_website/high; ≥30 →business_website/medium; otherwise →unknown/low. ABUSINESS_NAME_MISMATCHreason 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.uk → example.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:
- 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). - 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. WhenwebsiteTypeis"unknown"with a successfully analyzed page, no reason fires at all — genuinely ambiguous evidence contributes nothing either way.) - Content-based (only when
websiteType === "business_website"ANDanalysisStatus === "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 → scoring → qualification).
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}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). WhenanalysisStatusisn't"success", every signal isnull— 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/hasAppointmentLinkonly 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.hasOnlineBookingis a separate, stronger signal requiring a link to a known third-party booking platform (Calendly, Doctolib, Treatwell, SimplyBook, Booksy, ...). Phone/email detection prefers authoritativetel:/mailto:links over body-text regex matching, strips<script>/<style>content first, and rejects known placeholder email domains (e.g.wixpress.com,example.com).hasAddressuses 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 scoredhasAppointmentLink: falsebecause 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.
falsemeans "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-Lengthfirst, 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 asapify/google-maps-scraper— same Actor, same maintainer). ~426k users, 4.8★, priced from $1.50 / 1,000 scraped places. Configurable via theGOOGLE_MAPS_ACTOR_IDenv 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 ofmaxResults. - Deduplication: by
placeIdfirst, 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()), notActor.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 logicentities/ # Business, WebsiteFacts, WebsiteSignals, DigitalPresence, OpportunityAssessment, ResultItemschemas/ # Zod schemas for the Actor input and Dataset output boundariesports/ # Interfaces for future providers (Discovery, WebsiteAnalyzer, LLM)pipeline/ # Orchestration (runPipeline) and per-stage stubsscoring/ # opportunityScoringService — pure, deterministic, no I/Overification/ # verifyWebsiteRelevance, registrableDomain, identityMatching — pure, deterministic, no I/Oconfig/scoringWeights.ts # every scoring weight/threshold, documented, in one placeknownWebsitePlatforms.ts # known directory/social domains + directory-title keywords, documented, in one placeproviders/discovery/ # GoogleMapsApifyProvider + raw-record mapping/dedup/normalizationwebsite/ # HttpWebsiteAnalyzer + URL normalization + HTML signal extractionadapters/apify/ # Actor entrypoint, input/output mappingutils/logger.ts # Thin wrapper around Apify's loggertests/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 (
processBusinessinrunPipeline.ts); if a pure-function step throws unexpectedly for one business, that business's row is still produced (with the failure recorded inmeta.errorsandwebsite.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 inOUTPUT.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 largermaxResultscan increase discovery cost — but the exact cost cannot be inferred frommaxResultsalone. 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 onmaxResults, 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 atmin(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
opportunityScoreclearsllmQualificationThreshold(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. Convertusageto a dollar cost yourself using Anthropic's current published pricing for whicheverllmModelyou 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.exampledocuments both variables (APIFY_TOKEN=,ANTHROPIC_API_KEY=).- Copy it to
.envand fill in your keys;.envis git-ignored, so it can never be committed. - Locally,
dotenvloads.envautomatically (src/adapters/apify/main.tsimportsdotenv/configfirst). On the Apify platform itself, both variables are provided as Actor secrets/environment variables —.envis not deployed (it's git-ignored and not copied into the Docker image), so nothing needs to change for cloud runs. - If
APIFY_TOKENis missing when discovery runs, the Actor fails immediately with a clear, actionable message. ANTHROPIC_API_KEYis optional: if it's unset, the Actor skips wiring the LLM provider entirely and reportsqualificationas 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
Running locally
Requires Node.js 18+.
npm installcp .env.example .env # then edit .env and add your APIFY_TOKENnpm run buildmkdir -p storage/key_value_stores/defaultcat > storage/key_value_stores/default/INPUT.json << 'EOF'{"query": "dentists","location": "Amsterdam, Netherlands","maxResults": 10}EOFnode 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 oncenpm run test:watchnpm 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):
| Field | Type | Default | Notes |
|---|---|---|---|
query | string | — | required, e.g. "dentists" |
location | string | — | required, e.g. "Amsterdam, Netherlands" |
maxResults | number | 20 | capped 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) |
minOpportunityScore | number | 0 | deterministic filter on opportunity.score |
deterministicReasonCodes | string[] | none | deterministic filter on opportunity.reasons[].code |
requireBusinessWebsite | boolean | false | deterministic filter on verified website ownership |
opportunityTypes | string[] | none | LLM-dependent filter — requires a successful AI qualification |
minimumConfidence | "low"|"medium"|"high" | none | LLM-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, andrequireBusinessWebsiteare 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.opportunityTypesandminimumConfidencerequire 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 viameta.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 installcp .env.example .env # add APIFY_TOKEN and (optionally) ANTHROPIC_API_KEYnpm run buildnode 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: stdioAPIFY_TOKEN required,
ANTHROPIC_API_KEY optional). To deploy:
npx mcpize login # interactive browser login — must be run by a humannpx mcpize secrets set APIFY_TOKENnpx mcpize secrets set ANTHROPIC_API_KEY # optionalnpx mcpize doctor # pre-deploy validationnpx 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
maxWebsiteConcurrencyis 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 formaxResultsin 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 supportedmaxResultsrange.- 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[].codeis now constrained toOPPORTUNITY_REASON_CODES(core/entities/opportunityReasonCodes.ts, the single source of truth also used by the deterministic scoring service) plus oneOTHERfallback — enforced at THREE layers: the Anthropic structured-output JSON Schema (enumonreasons[].code, so the model cannot even generate an invented code), the ZodLLMQualificationResultSchema(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. Convertusageto cost yourself using current published Anthropic pricing if needed. - Anthropic's structured-output JSON Schema is a constrained subset (no
maxItemson 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
titleand 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 closedopportunityType/reason-code vocabulary, and a summary length limit — the LLM cannot return arbitrary text or modifydigitalPresenceScore/opportunityScore, which are already final by the time it runs. This does not make the system immune to this class of risk. TreatllmQualificationas 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
unknownrather than confidentlybusiness_websitewhen 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 inunknown, rarely a falsebusiness_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 onmaxResults. 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/techSignalsstay empty,hasOrderingLinkstays 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.
hasAddressonly 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.tsandwebsiteRelevanceVerifier.tslive undercore/instead (see "Project structure" above for why).- No ESLint configuration yet.