# Website Contact Page Finder: About, Pricing, Careers (`mmdincer/website-key-pages-finder`) Actor

Contact page finder for a list of websites. Returns each site's contact, about, team, pricing, careers, blog, press, support, docs, privacy and terms page URLs, with confidence and how each was found. Public pages only, robots.txt obeyed, no emails or phones. Not affiliated with any website.

- **URL**: https://apify.com/mmdincer/website-key-pages-finder.md
- **Developed by:** [Muhammet Mustafa Dincer](https://apify.com/mmdincer) (community)
- **Categories:** SEO tools, Lead generation, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.80 / 1,000 mapped websites

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

![Website contact page finder: a list of domains becomes the contact page, about page, pricing and careers page of each site](https://api.apify.com/v2/key-value-stores/vbQuJ5uhWVgUngr57/records/keypages-banner.png?signature=1nMFuYCWGO1yxUUoUkYHe)

### What is Website Contact Page Finder?

**Website Contact Page Finder** takes a list of domains and returns, for each one, the **contact page** and the other key pages: the **about page**, team, pricing, the **careers page**, blog, press, support, docs, privacy and terms. You get:

- **one row per website** (`recordType: "site"`) with eleven ready-to-use columns (`contactUrl`, `aboutUrl`, `pricingUrl`, `careersUrl` and so on), one address per page type;
- **yes/no columns** for a filter: `hasContactForm` (a contact form is present; the fields are not read), `hasPricing`, `hasCareers` and `hasDemoCta`;
- for every address, a **confidence** (`high`, `medium`, `low`), a **score from 0 to 100** and **how it was found** (`homepage_nav`, `homepage_footer`, `sitemap`, `common_path`), in the `pages` field, so you can trust the strong ones and review the weak ones;
- an honest **status** for every website, so a site that forbids access, shows a bot challenge or does not exist is **reported, not hidden**, and costs nothing;
- a `RUN_SUMMARY` record with counts per status and per page type.

It reads **public pages only**: the home page, `robots.txt`, the sitemap, the contact page when it needs to see whether a form is there, and, when something is still missing, a few common addresses such as `/pricing`. It **obeys `robots.txt`**, visits a site about **once per second**, never logs in, never reads an e-mail address, a phone number or a form field, and **stops** when a site shows a bot challenge. A run of 101 websites took 207 seconds on the platform.

### Quick answer: when should you (or an AI agent) pick this Actor?

**Pick this Actor when** you have a list of company domains and need to know **where things are on each site**: the page to send a sales form to, whether that page has a form, the pricing page to monitor, the careers page to watch, and whether the site asks for a demo. It is the step between "I have a domain" and "I fetch that page", and the step before a paid enrichment such as Apollo: keep the rows where `hasPricing` and `hasCareers` are true. It is also for lead lists, competitor tracking, due diligence, vendor reviews and AI agents that need to land on the right page without crawling the whole site.

**Pick something else when** you want the **contents** of those pages. This Actor returns addresses, not text, e-mail addresses, phone numbers or social profiles: it deliberately never reads them. Use a contact-details scraper for those, or fetch the addresses this Actor gives you with a content extractor. Also note that a site whose menu is drawn by JavaScript after the page loads may expose few links to a plain request; the Actor then falls back to the sitemap and common addresses and labels the result honestly.

**Defaults that work:** send `{"websites": ["linear.app", "vercel.com"]}` and you get all eleven page types, one best address each, about 7 requests per site. To get only what you need, add `"pageTypes": ["contact", "pricing", "careers"]`. To see alternatives (for example a sales contact and a support contact), add `"maxPagesPerType": 3`.

### At a glance

| | |
|---|---|
| **Does** | Finds contact, about, team, pricing, careers, blog, press, support, docs, privacy and terms page addresses for each domain, with a confidence score and the evidence. Adds `hasContactForm`, `hasPricing`, `hasCareers` and `hasDemoCta` |
| **Input** | `websites` (up to 1,000). Optional: `pageTypes`, `maxPagesPerType`, `probeCommonPaths`, `maxRequestsPerSite`, `concurrency`, `requestDelayMs` |
| **Output** | One `site` row per website in the dataset, plus `RUN_SUMMARY` in the key-value store |
| **Needs** | Nothing: no account on the target sites, no API key, no proxy |
| **Speed** | Measured on the platform: 3 websites in 9 s, 30 in 85 s, 101 in 207 s (default settings) |
| **Pricing** | Pay per event: $0.0025 per mapped website and $0.0002 per run on the Free plan. Websites without a key page, refusals and failures are free |
| **Obeys** | `robots.txt` (RFC 9309, including `Crawl-delay`), about one request per second per site, stops at bot challenges |
| **Does not** | Read e-mail addresses, phone numbers, form field values or personal data, submit a form, log in, solve CAPTCHAs, rotate addresses, bypass bot protection, or crawl a whole site |

### Key terms used in this README

- **Website:** a domain such as `linear.app`, or a link to its home page. Only the site is used, never the path you add. `www.` and the bare domain count as one website.
- **Page type:** one of eleven kinds of page: `contact`, `about`, `team`, `pricing`, `careers`, `blog`, `press`, `support`, `docs`, `privacy`, `terms`.
- **Key page:** the best address the Actor found for a page type on that website.
- **Confidence:** `high` (score 85 or more), `medium` (65 to 84) or `low` (50 to 64). Candidates below 50 are dropped.
- **Score:** 0 to 100, built from the address, the link text, where the link sits (menu or footer) and how many independent sources name the same address.
- **Found via:** `homepage_nav` (link in the page's menu), `homepage_footer` (link in the footer), `homepage_link` (link elsewhere on the page), `sitemap` (an address in the site's sitemap), `common_path` (a guessed address that answered and was checked).
- **Verified:** `true` when the Actor opened a guessed address and its title or path confirmed the page type, so a "page not found" screen or the home page in disguise is not returned.
- **Mapped website:** a website with at least one key page found (status `ok`). It is the only thing charged.
- **hasContactForm:** `true` when a contact form was seen on the home page or the contact page, `false` when those pages were read and none was there, `null` when the contact page could not be opened. The Actor does not read the fields.
- **hasPricing / hasCareers:** `true` when that page was found, `false` when you asked for it and the site was read but it was not found, `null` when you did not ask for that type or the site was not read.
- **hasDemoCta:** `true` when a link or button asks for a demo (for example "Book a demo"), `false` when the pages that were read have no such button, `null` when the home page was not read. The demo address is not stored.
- **robots.txt:** the file at the root of a site that tells automated visitors where they may go. This Actor reads it first and follows it.

### What can you use it for?

#### Lead lists: where do I send this?

Take domains from a CRM, a directory or a map scrape and add the **contact page** and **sales or support page** to each. `hasContactForm` tells you the page has a form, without reading the fields. Sales teams and outreach tools can open the right page without hunting through menus. The Actor returns the address only. It never reads the e-mail addresses on the page, and you should not use it to build mailing lists.

#### Before Apollo, Clay or any paid enrichment

Keep a company only when `hasPricing` and `hasCareers` are both `true`. That is the check people run in n8n so a lead list does not spend Apollo credits on sites with no pricing page and no careers page. `hasDemoCta` is the same kind of yes/no for a "Book a demo" button. A `null` means the Actor did not look, not that the answer is no. The Clay and n8n section below is a copy-paste version of this filter.

#### Competitor monitoring

Collect the **pricing**, **careers**, **blog** and **press** addresses of fifty competitors once, then point a page-change monitor or a content extractor at them. Rerun the Actor every few months to catch redesigned sites.

#### Compliance and vendor reviews

Find the **privacy policy** and **terms** pages of every vendor in a list, so a reviewer, an archive job or a document-fetching agent has the exact addresses.

#### Hiring and market research

The **careers** field points at the company's own page or at the **applicant tracking system** it uses. When the careers link goes to Greenhouse, Lever, Ashby, Workable, Recruitee, Personio, Teamtailor, BambooHR, SmartRecruiters, Workday, Breezy, Jobvite or iCIMS, the Actor labels it with `atsProvider`, so you know which board to read next. For the jobs themselves, see the sibling Actor **ATS Jobs Monitor** (`mmdincer/ats-jobs-monitor`).

#### Feeding an LLM agent

Through MCP an agent can call the Actor with a domain and receive a flat row with the addresses it needs, instead of crawling or guessing. Because every row has the same keys and an explicit `status`, an agent can tell "the site has no pricing page" from "the site blocked us".

### How does it work?

1. Each entry is cleaned up into a website. Links lose their path, `www.` duplicates are merged, and entries that are not public websites are refused with a reason: IP addresses, `localhost`, internal and private network names, non-standard ports, links with a password inside, and **e-mail addresses** (which are refused and not echoed back).
2. For every website the Actor first requests **`robots.txt`** and reads it following RFC 9309: groups, the most specific rule wins, `Allow` beats `Disallow` on a tie, `*` and `$` patterns, `Sitemap` lines, and `Crawl-delay`. If the home page is not allowed, the site is reported as `robots_disallowed` and **nothing more is requested**.
3. It reads the **home page** and collects its links. Each link is scored by its address (in several languages, such as `/kontakt`, `/precios`, `/a-propos`), by its link text, and by where it sits: a menu link scores higher than a body link, and a footer link is a strong signal.
4. **Only if some page types are still missing** it reads the site's **sitemap** (from `robots.txt` or the usual location) and picks out the page-like addresses.
5. **Only if types are still missing and `probeCommonPaths` is on** it tries a few common addresses (`/contact`, `/about`, `/pricing`, `/careers`, ...). A guess is kept only if the page answers with a real page whose title or address names the type, not the home page, not a "not found" page and not a redirect to another site. Optional steps stop when the **request budget** per website is used up.
6. If a contact address was found and the home page did not already show a contact form, the Actor **opens that contact page once**, still inside the request budget and still only where `robots.txt` allows. It looks for a form element or a known embedded form (a message box, a contact label, or a name-and-email form on the contact page). A search box, a newsletter signup, a login and a cookie banner do not count. **Nothing inside the form is stored:** no field names that hold a value, no placeholders, no addresses. If the home page already has a contact form, the contact page is not opened again. `hasDemoCta` is decided from the link and button labels already on the pages that were read ("Book a demo", or a path such as `/request-a-demo`). The demo address is not stored.
7. Redirects are followed one hop at a time, and **every hop is checked against `robots.txt`**. A redirect to another site is refused, except for the home page itself (for example `example.com` to `www.example.com`).
8. Rows are written in the order of your input list, one row per website.
9. If a site answers **403 or 429**, or shows a **bot challenge** (a "checking your browser" page, a CAPTCHA, a Cloudflare or similar wall), the Actor **stops for that site** and marks it `bot_challenge` or `refused`. It does not retry, switch address or look for a way round. The same rule applies to the single contact-page request: a challenge there is not retried, pages already found are kept, and `hasContactForm` stays `null`.

Pages that are not in the home page's links, not in the sitemap and not at a common address are not found. That is by design: the Actor does not crawl.

### How to find the key pages of a list of websites (step by step)

1. Open the Actor and keep the prefilled websites, or paste your own domains, one per line.
2. Leave **Page types to find** at all eleven, or keep only the ones you need to save requests.
3. Click **Start**. A few websites finish in seconds; a hundred take about three and a half minutes.
4. Open the **Sites** view of the dataset for the main columns, including `hasContactForm`, `hasPricing`, `hasCareers` and `hasDemoCta`, or the **Details** view for confidence, score and evidence. Download JSON, CSV or Excel.
5. Check the `status` column. Anything other than `ok` has a `message` that says why.

### Input

| Field | Type | Default | Meaning |
|---|---|---|---|
| `websites` | array of strings | required | Up to 1,000. A domain such as `example.com` or a link to the home page. Only the site is used, never the path. `www.` and the bare domain are one website |
| `pageTypes` | array | all eleven | Which types to look for: `contact`, `about`, `team`, `pricing`, `careers`, `blog`, `press`, `support`, `docs`, `privacy`, `terms`. Fewer types means fewer requests |
| `maxPagesPerType` | integer 1 to 3 | 1 | Addresses returned per type, best first. The eleven `*Url` columns always hold the best one; the rest are in `pages` |
| `probeCommonPaths` | boolean | `true` | When a type is still missing after the home page and the sitemap, try a few common addresses and keep only verified ones |
| `maxRequestsPerSite` | integer 3 to 20 | 8 | Upper limit of requests for one website, `robots.txt` and redirects included. The home page and `robots.txt` always come first |
| `concurrency` | integer 1 to 20 | 10 | Websites visited in parallel. Requests to one site are always one at a time and spaced apart |
| `requestDelayMs` | integer 1000 to 10000 | 1000 | Minimum wait between two requests to one site. A longer `Crawl-delay` in `robots.txt` (up to 10 s) is used instead. A site asking for more than 10 s is skipped as `crawl_delay_too_long` |

The delay has a **floor of 1000 ms on purpose**. This Actor is built to be polite and cannot be set to hammer a site.

### Output

One dataset, one kind of row (`recordType: "site"`), two views: **Sites** and **Details**.

![Output dataset: one row per website with the best contact, about, pricing and careers address, and the evidence behind each page of one website, from a real run](https://api.apify.com/v2/key-value-stores/vbQuJ5uhWVgUngr57/records/keypages-sample-output.png?signature=MoRRRnlwKgSiQ1TOKZgm)

#### Site rows

| Field | Meaning |
|---|---|
| `recordType` | `site` |
| `input` | The entry as you wrote it |
| `domain`, `website` | Host name, and the home page address after redirects. Pages are listed relative to this site |
| `status` | `ok`, `no_pages_found`, `robots_disallowed`, `bot_challenge`, `refused`, `unreachable`, `http_error`, `crawl_delay_too_long`, `invalid_input` or `not_fetched`. See below |
| `message` | Why a website has a status other than `ok`, or a note such as "Stopped early" |
| `pagesFound` | Number of distinct page types found |
| `contactUrl`, `aboutUrl`, `teamUrl`, `pricingUrl`, `careersUrl`, `blogUrl`, `pressUrl`, `supportUrl`, `docsUrl`, `privacyUrl`, `termsUrl` | The best address for each type, or `null` |
| `hasContactForm` | `true`, `false` or `null`. Presence of a contact form only. See the terms above |
| `hasPricing`, `hasCareers` | `true` if that page was found, `false` if you asked for it and the site was read but it was missing, `null` if you did not ask or the site was not read |
| `hasDemoCta` | `true` if a demo button or link was seen, `false` if the read pages had none, `null` if the home page was not read. The address is not included |
| `pages` | Every address found (up to `maxPagesPerType` per type) with `type`, `url`, `confidence`, `score`, `foundVia`, `evidence` (all sources that named it), `verified`, `anchorText` (the link text, at most 80 characters) and `atsProvider` |
| `homepageTitle`, `language` | The home page `<title>` and its declared language |
| `robotsTxt` | `found`, `missing` or `unavailable` (the site refused or could not be reached) |
| `crawlDelaySeconds` | The `Crawl-delay` the site asked for, if any |
| `sitemapRead` | `true` if a sitemap was read |
| `requests` | Requests made for this website, `robots.txt` included |
| `stoppedReason` | Set when the Actor stopped early (a refusal, a challenge, the request budget) |
| `durationMs`, `scrapedAt` | How long the website took, and when |

#### Statuses

| `status` | Meaning | Charged |
|---|---|---|
| `ok` | At least one key page found | Yes |
| `no_pages_found` | The site was read and no key page was found (a one-page site, an app behind a login, a site drawn by JavaScript) | No |
| `robots_disallowed` | `robots.txt` does not allow the Actor to read the home page | No |
| `bot_challenge` | The site showed bot protection. The Actor stopped | No |
| `refused` | HTTP 403 or 429 without a recognised challenge. The Actor stopped | No |
| `unreachable` | No answer, a DNS failure, or a server that kept failing | No |
| `http_error` | The home page answered with an error such as 404 | No |
| `crawl_delay_too_long` | `robots.txt` asks for a `Crawl-delay` above 10 seconds | No |
| `invalid_input` | The entry is not a public website, with the reason in `message` | No |
| `not_fetched` | Not visited because the spending limit was reached | No |

If **no** website in a run could be read at all, the run ends as failed and says why. Otherwise it succeeds, and the statuses tell you what happened to each site.

#### Run summary

`RUN_SUMMARY` in the key-value store: websites requested and mapped, count per status, count per page type, requests made, how many sites forbade access in `robots.txt`, websites not visited because of the spending limit, duplicates removed, whether the run stopped at the limit, the settings used, and the first problems with their reasons.

### How much does it cost?

The Actor uses pay-per-event pricing. On the Free plan:

| Event | Price | When it is charged |
|---|---|---|
| Actor Start | $0.0002 | Once per run |
| Mapped website | $0.0025 | Once per website with at least one key page found |

Websites that forbid access in `robots.txt`, refuse or challenge the request, cannot be reached, have no key page, are duplicates or invalid entries, or were not visited because of a spending limit are **free**. Paid plans get lower prices per website.

Measured examples, charged at Free-plan prices (platform runs on 2026-11-04):

| Run | Websites | Mapped | Requests | Run time | You pay |
|---|---|---|---|---|---|
| The prefill: `linear.app`, `vercel.com`, `gitlab.com` | 3 | 3 | 21 | 9 s | $0.0077 |
| 30 well-known sites | 30 | 27 | 204 | 85 s | $0.0677 |
| 101 well-known sites, default settings | 101 | 94 | 702 | 207 s | $0.2352 |

In the 101-site run, 94 sites were mapped, 4 showed a bot challenge, 1 could not be reached, 1 had no key page and 1 asked for a `Crawl-delay` above 10 seconds. These are the only ones that were not charged. Across the 94 mapped sites the Actor found on average 8.2 of the 11 page types: `docs` on 89, `blog` on 86, `about` on 78, `privacy` on 78, `support` on 76, `contact` on 73, `pricing` on 69, `careers` on 68, `terms` on 68, `press` on 56 and `team` on 34. Of the 775 addresses returned, 577 were `high` confidence, 126 `medium` and 72 `low`.

To cap spending, lower the number of websites, or set a maximum cost per run in the Apify run options: the Actor stops cleanly when the limit is reached and marks the remaining websites `not_fetched`.

#### Unit price against SiteEnrich

[SiteEnrich](https://www.capterra.com/p/10044023/SiteEnrich/) is a website-enrichment API aimed at the same kind of list. Capterra lists the Starter plan at **$19 per month for 2,000 requests**, which is **$0.0095 per request**, and the Pro plan at $49 per month for 10,000 requests, which is $0.0049 per request. That listing was updated on 21 October 2026. Check the current price before you rely on it.

| | This Actor (Free plan) | SiteEnrich Starter | SiteEnrich Pro |
|---|---|---|---|
| Unit price | **$0.0025** per mapped website, plus $0.0002 once per run | **$0.0095** per request ($19 / 2,000) | $0.0049 per request ($49 / 10,000) |
| One website, its own run | $0.0027 | $0.0095 | $0.0049 |
| 100 websites, one run, all mapped | $0.2502 | $0.95 | $0.49 |
| What you get | Page addresses, contact-form presence, `hasPricing`, `hasCareers`, `hasDemoCta`. No e-mail addresses | Their listing includes e-mail extraction, social profiles and business signals | Same as Starter, plus their higher quota |
| Not mapped (robots, challenge, no page) | Free | A request, on their pricing page's terms | A request, on their pricing page's terms |

The two products are not the same thing. SiteEnrich's listing sells e-mail extraction. This Actor does not return e-mail addresses, phone numbers or social profiles. The comparison is the price of looking up one website.

### How does it compare with other Actors?

Checked on the Apify Store on 2026-11-04. Most "contact" Actors on the Store are built to **collect e-mail addresses and phone numbers**. This one is built to **map where the pages are**, and to stay within what a polite visitor may do.

| | This Actor | `sandy_yclept/contact-page-finder` | `emastra/website-contact-scraper` | `automation-lab/website-contact-finder` |
|---|---|---|---|---|
| Main job | Addresses of eleven page types | The contact page and whether it has a form | E-mails, phones, social profiles | E-mails, phones, social profiles, contact pages |
| Page types beyond contact | About, team, pricing, careers, blog, press, support, docs, privacy, terms | No | No | No |
| Confidence and evidence per page | Yes | Not documented | Not documented | Not documented |
| Reads e-mail addresses and phone numbers | No, by design | Not documented | Yes | Yes |
| Detects contact forms | Yes, presence only. No field values and no e-mail addresses | Yes | Not documented | Not documented |
| Yes/no columns for pricing, careers and a demo button | Yes (`hasPricing`, `hasCareers`, `hasDemoCta`) | Not documented | Not documented | Not documented |
| Obeys `robots.txt` | Yes, checked at every redirect | Not documented | Not documented | Not documented |
| Stops at bot challenges | Yes, never bypasses | Not documented | Not documented | Not documented |
| Needs a proxy | No | Optional (proxy input) | Optional | Optional |
| Price (Free plan) | $0.0025 per mapped website + $0.0002 start | $0.005 per contact page + $0.00005 start | $0.004 per domain + $0.00005 start | $0.001 per website + $0.035 start (e-mail checks extra) |

In short: this Actor now reports **whether a contact form is present**, and it still does not read the form or collect e-mail addresses. `sandy_yclept/contact-page-finder` is the specialist if the contact page alone is the whole job. If you need **e-mails and phone numbers**, use a contact scraper and check that you may use that data. If you need **a map of the key pages of each site**, with confidence, evidence and yes/no columns for a pricing page, a careers page and a demo button, and a tool that refuses to do what a site has asked automated visitors not to do, this Actor is built for that. Cells marked "Not documented" mean the other Actor's page does not mention the feature; it may still do it.

### Is it legal to find key pages of websites?

**This Actor reads only public pages that any visitor can open without logging in, and it follows the rules sites publish for automated visitors.** Please read this section before you use it at scale.

- It sends **plain HTTP requests** for the home page, `robots.txt`, a sitemap and a few common addresses. It uses no login, no cookies, no accounts and no hidden or paid content.
- It **obeys `robots.txt`**. If the home page is not allowed, nothing else is requested. Every redirect hop is checked again. It identifies itself with a user agent that names the Actor and its Store page, and the rules for `KeyPagesFinder` in `robots.txt` apply to it.
- It is **polite**: one request at a time per site, at least one second apart, and longer if `Crawl-delay` asks for it. A site that asks for more than ten seconds is skipped. Different sites are visited in parallel, but a single site never sees more than one request at a time.
- If a site answers **403 or 429**, or shows a **bot challenge**, the Actor **stops** for that site. It does not retry, rotate addresses, change its identity, solve CAPTCHAs or look for another way in. That is final for the run.
- It collects **no personal data**. It never opens `mailto:` or `tel:` links, drops any address or link text that contains an e-mail address or phone number, refuses an e-mail address typed into the input and does not echo it, and keeps link text to at most 80 characters. It returns addresses of pages, not their contents. When it looks for a contact form it reads only that a form element is there. Values, placeholders and addresses inside the form are not copied to the output, and the form is never submitted.
- Website terms of use may still restrict automated access even to public pages, and some sites object to it. Whether a particular use is lawful depends on the site's terms, the data you do something with afterwards and the law that applies to you (for example the GDPR and the rules on unsolicited marketing). **You are responsible for how you use the output.** Do not use it to build contact lists for unsolicited messages.
- **Takedown:** if a rights holder or a website owner asks for something to be removed, or for access to stop, we will honour the request, up to and including removing the Actor. Use the Issues tab of this Actor.

Not affiliated with, endorsed by or sponsored by Greenhouse, Lever, Ashby, Workable or any website it visits. All names are the property of their owners.

### Using the API

#### MCP (AI agents)

**Apify MCP server (ChatGPT, Claude, Cursor and other MCP clients).** Connect `https://mcp.apify.com`, then:

1. `search-actors` with a query such as `contact page finder about page careers page`.
2. `fetch-actor-details` for `mmdincer/website-key-pages-finder` to read the input schema.
3. `call-actor` with the input below.
4. `get-dataset-items` with the returned dataset ID.

```json
{
  "actor": "mmdincer/website-key-pages-finder",
  "input": {
    "websites": ["linear.app", "vercel.com"],
    "pageTypes": ["contact", "pricing", "careers"]
  },
  "callOptions": { "maxTotalChargeUsd": 0.1 }
}
```

To give an MCP client this Actor as its own tool (no search step), connect `https://mcp.apify.com?tools=mmdincer/website-key-pages-finder`.

#### JavaScript

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

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('mmdincer/website-key-pages-finder').call({
    websites: ['linear.app', 'vercel.com', 'gitlab.com'],
    pageTypes: ['contact', 'pricing', 'careers'],
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const site of items.filter((item) => item.status === 'ok')) {
    console.log(site.domain, site.contactUrl, site.pricingUrl, site.careersUrl);
}
```

#### Python

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("mmdincer/website-key-pages-finder").call(run_input={
    "websites": ["linear.app", "vercel.com", "gitlab.com"],
    "pageTypes": ["contact", "pricing", "careers"],
})
items = client.dataset(run["defaultDatasetId"]).list_items().items
for site in items:
    if site["status"] == "ok":
        print(site["domain"], site["contactUrl"], site["pricingUrl"], site["careersUrl"])
```

#### curl

```bash
curl -s -X POST "https://api.apify.com/v2/acts/mmdincer~website-key-pages-finder/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"websites": ["linear.app"], "pageTypes": ["contact", "pricing"]}'
```

### Clay and n8n

Use the yes/no columns as a gate before a paid enrichment. Keep a company when `hasPricing` and `hasCareers` are both `true`. Add `hasDemoCta` or `hasContactForm` when you want a tighter list. A `null` means the Actor did not look (you left that page type out, or the site was not read). Do not treat `null` as "no".

#### Clay

Add an HTTP API enrichment column on a table that already has a domain column:

- Method: `POST`
- URL: `https://api.apify.com/v2/acts/mmdincer~website-key-pages-finder/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN`
- Body:

```json
{
  "websites": ["{{Domain}}"],
  "pageTypes": ["contact", "pricing", "careers"]
}
```

Map `hasPricing`, `hasCareers`, `hasDemoCta`, `hasContactForm` and `contactUrl` from the first item of the response. Put the Apollo column behind a filter that keeps the row only when `hasPricing` and `hasCareers` are true.

One Clay row is one Actor run, so the $0.0002 start fee is paid on every row. For a long list, run the Actor once with every domain in `websites` (up to 1,000) and import that dataset into Clay. The start fee is then paid once. See the unit-price table above.

#### n8n

1. Add an HTTP Request node. Method POST. URL:

`https://api.apify.com/v2/acts/mmdincer~website-key-pages-finder/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN`

Body (JSON), one run for the whole list:

```json
{
  "websites": ["linear.app", "vercel.com", "gitlab.com"],
  "pageTypes": ["contact", "pricing", "careers"]
}
```

The endpoint returns one JSON item per website. Let n8n split that array into items.

2. Add an IF node before the Apollo node:

```
{{ $json.status === "ok" && $json.hasPricing === true && $json.hasCareers === true }}
```

Only the true branch calls Apollo. `hasDemoCta` and `hasContactForm` can be extra conditions on that same expression. A site that showed a bot challenge has `hasPricing` set to `null` and stays on the false branch, so the credit is not spent. Put the token in an n8n credential or an environment variable, not in a shared workflow export.

#### Review the weak results only

Keep the strong addresses and send the rest to a person:

```javascript
const weak = items
    .filter((site) => site.status === 'ok')
    .flatMap((site) => site.pages.filter((page) => page.confidence !== 'high').map((page) => ({ domain: site.domain, ...page })));
```

### FAQ

#### Does it collect e-mail addresses or phone numbers?

No, and it is built so that it cannot. It never opens `mailto:` or `tel:` links, drops any address or link text that contains an e-mail address or a phone number, and returns only the address of the page. A contact form is reported as yes or no. The fields, the placeholders and any address typed into the form are not returned. If you need contact details, you need a different tool and your own legal basis.

#### Does it detect contact forms?

Yes, as a yes/no on `hasContactForm`. It counts a message box, a form labelled as contact, a name-and-email form on the contact page, or an embedded form from a known form host. It does not count a search box, a newsletter signup, a login or a cookie banner. It does not submit the form. If `robots.txt` disallows the contact page, the request budget is used up, or the page answers with a bot challenge, `hasContactForm` is `null` and the Actor does not try another way in.

#### What do hasPricing, hasCareers and hasDemoCta mean?

`hasPricing` and `hasCareers` are true when that page was found. They are false when you included that type and the site was read but the page was not found. They are null when you did not ask for that type, so a run with `"pageTypes": ["contact"]` will not claim the site has no pricing page. The default asks for all eleven types, so a default run fills both columns. `hasDemoCta` is true when a link or button on a page that was read asks for a demo. The demo URL is not included, because a booking link can carry an address.

#### What happens if a site blocks the Actor?

On a 403, a 429 or a bot challenge the Actor stops for that site, marks the row `bot_challenge` or `refused` and does not charge for it. It does not retry or look for another route. In the 101-site run, 4 well-known sites did this. Run again later if you wish; the answer may differ, and for some sites it never will.

#### What if `robots.txt` does not allow the Actor?

The row says `robots_disallowed` and nothing else is requested from that site. You are not charged. If a `robots.txt` cannot be reached because the server fails, the site is reported as `unreachable` and not visited, because the Actor cannot tell what is allowed. If `robots.txt` is missing (a 404, or an HTML page returned instead), the Actor treats everything as allowed, as RFC 9309 says.

#### Why did a site return few or no pages?

The home page may be built by JavaScript, so a plain request sees few links; the site may be a single page or an app behind a login; or the pages may use unusual names. The Actor then falls back to the sitemap and common addresses. If nothing is found the row says `no_pages_found`, and you are not charged. Raising `maxRequestsPerSite` helps only when the budget was the limit (see `stoppedReason`).

#### How accurate are the addresses?

Addresses linked from the menu or footer and confirmed by the sitemap score `high` and were almost always right in our checks. `medium` and `low` addresses come from a single weaker source (for example a guessed common address, which is verified, or a sitemap-only entry) and deserve a look. For example, a site's terms page found only in the sitemap may be a product-specific terms page. The `evidence` field shows every source that named the address.

#### Why is there a `support` and a `contact` address that are the same?

Many sites use one page for both. The same address can appear under several types. `pagesFound` counts distinct types, not distinct addresses.

#### Does it follow redirects?

Yes, one hop at a time, and each hop is checked against `robots.txt`. A redirect to a different site is refused, except for the home page of the site you entered (for example from the bare domain to `www.`). A refused redirect to another site is not followed.

#### Why can't I set the delay below one second?

The Actor is built to be a polite visitor. The minimum is 1000 ms between two requests to the same site, and a site's own `Crawl-delay` (up to 10 s) takes precedence if it is longer.

#### How many websites can I send?

Up to 1,000 per run. For longer lists, split them into several runs. Duplicates (including `www.` and bare domain) are merged and not charged twice.

#### What about careers pages on Greenhouse, Lever and similar boards?

When the company's careers link points to a known applicant tracking system, the address is returned with `atsProvider` set (for example `greenhouse`). Reading the jobs there is a different job: see `mmdincer/ats-jobs-monitor`.

#### Is the output deterministic?

For the same site content, yes: same scores, same order. Sites change, so two runs months apart may differ.

#### Where can I report a problem or request a feature?

Use the Issues tab of this Actor. Please include the run ID and the website that failed.

# Changelog

This Actor's version history is a separate document: https://apify.com/mmdincer/website-key-pages-finder/changelog.md

# Actor input Schema

## `websites` (type: `array`):

One per line: a domain such as <code>example.com</code> or a link to the home page. Only the site is used, never the path. <code>www.</code> and the bare domain count as one website. Up to 1,000 per run. Private, local and internal addresses, IP addresses and e-mail addresses are refused.

## `pageTypes` (type: `array`):

Which key pages to look for. Empty means all eleven: contact, about, team, pricing, careers, blog, press, support, docs, privacy and terms. Looking for fewer types can save requests when a site hides some of them.

## `maxPagesPerType` (type: `integer`):

How many addresses to return per page type, best first. 1 gives the single best contact page, careers page and so on. Use 2 or 3 when a site has several (for example a sales contact and a support contact).

## `probeCommonPaths` (type: `boolean`):

When a page type is still missing after the home page and the sitemap, request a few common addresses such as <code>/contact</code> or <code>/pricing</code>. A guess counts only if the page answers, is not the home page in disguise and its title or address names that type. Turn off to read the home page and the sitemap only.

## `maxRequestsPerSite` (type: `integer`):

Upper limit of requests for one website, <code>robots.txt</code> and redirects included. The home page and robots.txt always come first; the sitemap and common addresses are skipped when the budget is used up. 8 finds most pages; a site whose menu is built by JavaScript may need more.

## `concurrency` (type: `integer`):

How many different websites are visited in parallel. Requests to the same site are always one at a time and spaced apart, so this never raises the load on a single site.

## `requestDelayMs` (type: `integer`):

Minimum wait between two requests to the same host. 1000 is about one request per second. If a site's <code>robots.txt</code> asks for a longer <code>Crawl-delay</code> (up to 10 s), that is used instead; a longer one skips the site. The Actor never retries a refusal: on HTTP 403, 429 or a bot challenge it stops for that site.

## Actor input object example

```json
{
  "websites": [
    "example.com",
    "https://www.example.org"
  ],
  "pageTypes": [
    "contact",
    "about",
    "team",
    "pricing",
    "careers",
    "blog",
    "press",
    "support",
    "docs",
    "privacy",
    "terms"
  ],
  "maxPagesPerType": 1,
  "probeCommonPaths": true,
  "maxRequestsPerSite": 8,
  "concurrency": 10,
  "requestDelayMs": 1000
}
```

# Actor output Schema

## `sites` (type: `string`):

One row per website with the best contact, about, team, pricing, careers, blog, press, support, docs, privacy and terms URL, yes/no columns for a contact form, pricing, careers and a demo button, and the status of the visit.

## `details` (type: `string`):

The same rows with the evidence behind each address: confidence, score, how it was found and the robots.txt and request details of the visit.

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

Websites mapped, statuses, page types found, requests made and the first problems.

# 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 = {
    "websites": [
        "linear.app",
        "vercel.com",
        "gitlab.com"
    ],
    "pageTypes": [
        "contact",
        "about",
        "team",
        "pricing",
        "careers",
        "blog",
        "press",
        "support",
        "docs",
        "privacy",
        "terms"
    ],
    "maxPagesPerType": 1
};

// Run the Actor and wait for it to finish
const run = await client.actor("mmdincer/website-key-pages-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 = {
    "websites": [
        "linear.app",
        "vercel.com",
        "gitlab.com",
    ],
    "pageTypes": [
        "contact",
        "about",
        "team",
        "pricing",
        "careers",
        "blog",
        "press",
        "support",
        "docs",
        "privacy",
        "terms",
    ],
    "maxPagesPerType": 1,
}

# Run the Actor and wait for it to finish
run = client.actor("mmdincer/website-key-pages-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 '{
  "websites": [
    "linear.app",
    "vercel.com",
    "gitlab.com"
  ],
  "pageTypes": [
    "contact",
    "about",
    "team",
    "pricing",
    "careers",
    "blog",
    "press",
    "support",
    "docs",
    "privacy",
    "terms"
  ],
  "maxPagesPerType": 1
}' |
apify call mmdincer/website-key-pages-finder --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mmdincer/website-key-pages-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/zXHiTkVKVCxI5cLOF/builds/GOQxbrTdtivgcVg2n/openapi.json
