# Business Opportunity Finder (`nordapi/business-opportunity-finder`) Actor

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.

- **URL**: https://apify.com/nordapi/business-opportunity-finder.md
- **Developed by:** [Nord](https://apify.com/nordapi) (community)
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 business opportunity founds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Business Opportunity Finder

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

```json
{
  "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:

```json
{ "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.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:

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 → **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}`. 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

```json
{
  "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.

```json
{
  "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`](https://www.npmjs.com/package/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](https://console.apify.com/),
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](https://console.anthropic.com/), go to
**Settings → API keys** (directly: <https://console.anthropic.com/settings/keys>).

```env
APIFY_TOKEN=apify_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

### Running locally

Requires Node.js 18+.

```bash
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](https://docs.apify.com/cli) installed:

```bash
apify run
```

### Running tests

```bash
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`](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`, 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):

```bash
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:

```bash
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.

# Actor input Schema

## `query` (type: `string`):

The type of business to search for, e.g. "dentists" or "hairdressers".

## `location` (type: `string`):

Where to search, e.g. "Amsterdam, Netherlands".

## `maxResults` (type: `integer`):

Maximum number of businesses to process. Capped at 200 — the underlying Google Maps discovery Actor is never asked for more than 200 raw results in a single run, so higher values cannot be satisfied.

## `analysisDepth` (type: `string`):

Reserved for a future deeper (browser-based) analysis mode. Every website is currently analyzed with a single plain HTTP request regardless of this setting — it has no effect yet.

## `maxWebsiteConcurrency` (type: `integer`):

How many businesses to analyze/verify/score/qualify in parallel. Higher values finish faster but send more simultaneous requests to discovered websites (and, when a business qualifies, to the LLM). Keep this modest for typical runs.

## `llmModel` (type: `string`):

The Anthropic Claude model used for opportunity qualification. Only used if ANTHROPIC\_API\_KEY is configured in the Actor's environment variables/secrets — otherwise LLM qualification is skipped entirely (not an error).

## `llmTemperature` (type: `integer`):

Sampling temperature for the LLM qualification call, from 0 (most consistent, recommended) to 1 (more varied wording for the same evidence). Some newer models ignore this setting.

## `llmTimeoutMs` (type: `integer`):

Maximum time to wait for a single LLM response, in milliseconds, before treating the call as failed (the business still gets a full deterministic result either way).

## `llmMaxOutputTokens` (type: `integer`):

Maximum number of tokens the LLM may generate per qualification response. The response is a short structured JSON object, so the default is generally sufficient.

## `llmQualificationThreshold` (type: `integer`):

Only businesses whose deterministic opportunityScore is at or above this value are sent to the LLM — a cost-control gate. The default (25) is the boundary between the "low" and "medium" opportunity tiers: below it, there usually isn't enough signal for a qualitative pass to add value. Lower this to qualify more businesses (higher LLM cost); raise it to qualify fewer.

## Actor input object example

```json
{
  "maxResults": 10,
  "analysisDepth": "standard",
  "maxWebsiteConcurrency": 3,
  "llmModel": "claude-haiku-4-5-20251001",
  "llmTemperature": 0,
  "llmTimeoutMs": 20000,
  "llmMaxOutputTokens": 1024,
  "llmQualificationThreshold": 25
}
```

# Actor output Schema

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

Local businesses discovered for the given query/location, each analyzed for potential website, booking, contact, mobile, and digital-presence opportunities. Every item includes the underlying business/website facts, deterministic digitalPresence/opportunity scores with named reasons, and (when run) an optional, separate LLM qualification. See the Dataset tab's schema for the full field reference.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("nordapi/business-opportunity-finder").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("nordapi/business-opportunity-finder").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call nordapi/business-opportunity-finder --silent --output-dataset

```

## MCP server setup

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

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/X84zDveMCcWJcNpjl/builds/ncWDZcuG2MYcWgNJV/openapi.json
