You have a list of target accounts. Before you can write to anyone, you need to know who runs marketing at each one, or who the founder is, or whether there's a head of operations at all. The usual answer is a paid contact database, which is fast but often stale: people change jobs, and databases lag.

There's a more current source most sales teams overlook: the company's own website. Agencies, law and accounting firms, consultancies, construction firms and most small and mid-sized companies publish a team or leadership page, and they keep it up to date because clients read it.

This post shows how to turn those pages into a structured list with Python.

Disclosure: I'm Aurelix, and I built the Decision-Maker Finder Actor used below. It's a paid tool (prices in the Cost section).

What the tool does for each domain

  1. You give it domains, not names. Your account list is probably company names; the website domain is the reliable key.
  2. It finds the right pages. Team, leadership, management, "about us", in the languages your market uses ("équipe", "direction" in Quebec, for example), by following the homepage's links rather than guessing URLs.
  3. It extracts name + title pairs. Team cards almost always put a name next to a job title. Everything else that also contains names is skipped: testimonials, blog authors, client quotes, partner logos, news.
  4. It classifies titles. "VP, Growth Marketing" and "Directrice du marketing" should both land in marketing, at VP and director level. So you filter by role instead of reading every title.
  5. It leaves out people who aren't operators. Board members, advisors and investors are listed on leadership pages but rarely buy anything. A title that names another organisation ("CEO of X", "Former CTO of Y") belongs to a bio line, not a current role.
  6. It never guesses emails. If the page publishes a work email next to the person, it's kept. If not, the field is empty. Guessing first.last@ and then "verifying" by pinging mail servers is unreliable, and it's a poor basis for B2B outreach under CASL and GDPR.
  7. It keeps the source URL on every row so anyone can check where a name came from.

The tool

The Decision-Maker Finder Actor implements those steps. Input:

  • domains (required): one company per entry, as a bare domain or URL.
  • seniorities: any of founder_owner, c_suite, partner, vp, head, director, manager, staff. Empty means the default decision-maker levels: founder_owner, c_suite, partner, vp, head and director.
  • functions: any of executive, sales, marketing, engineering, product, hr, finance, legal, operations, customer_service, other. Empty means all.
  • maxPagesPerDomain (1 to 25, default 10): pages read per company, team and leadership pages first.
  • maxPeoplePerCompany (default 25): cap per company, most senior first.

Each person is one row:

{ "name": "Leanne Radcliffe", "title": "Chief Talent Officer", "seniority": "c_suite", "function": "hr", "work_email": null, "work_phone": null, "linkedin_url": null, "twitter_url": null, "company_name": "RLB", "domain": "rlb.ca", "website": "https://www.rlb.ca/", "company_linkedin_url": "https://www.linkedin.com/company/rlb-llp", "source_url": "https://www.rlb.ca/team-members/", "scraped_at": "2026-11-04T11:26:18Z" } 

Note work_email and linkedin_url are null. That's the normal case: most companies publish names and titles without personal emails, and many team pages link no profiles.

The script

Say you have accounts.csv with a website column. This reads it, finds marketing and sales leaders plus founders and C-level people, and writes one row per person and a one-line-per-company summary.

pip install apify-client 
import csv import os from collections import defaultdict from urllib.parse import urlparse from apify_client import ApifyClient SENIORITY_ORDER = ["founder_owner", "c_suite", "partner", "vp", "head", "director", "manager", "staff"] def domain_of(url): if not url: return None if "://" not in url: url = "https://" + url host = (urlparse(url).hostname or "").lower() return host[4:] if host.startswith("www.") else host or None def read_accounts(path): with open(path, newline="", encoding="utf-8") as f: return sorted({d for d in (domain_of(r.get("website")) for r in csv.DictReader(f)) if d}) def find_people(domains): client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("aurelix/decision-maker-finder").call( run_input={ "domains": domains, "seniorities": ["founder_owner", "c_suite", "vp", "head", "director"], "functions": ["executive", "marketing", "sales"], "maxPagesPerDomain": 10, "maxPeoplePerCompany": 25, } ) return list(client.dataset(run["defaultDatasetId"]).iterate_items()) def main(): domains = read_accounts("accounts.csv") people = find_people(domains) def rank(p): level = p.get("seniority") return SENIORITY_ORDER.index(level) if level in SENIORITY_ORDER else len(SENIORITY_ORDER) people.sort(key=lambda p: (p.get("domain") or "", rank(p))) fields = ["company_name", "domain", "name", "title", "seniority", "function", "work_email", "work_phone", "linkedin_url", "company_linkedin_url", "source_url"] with open("decision_makers.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") writer.writeheader() writer.writerows(people) by_company = defaultdict(list) for p in people: by_company[p.get("domain")].append(p) with open("accounts_summary.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["domain", "people_found", "top_person", "top_title", "marketing_lead", "sales_lead", "has_any_email"]) for domain in domains: found = by_company.get(domain, []) top = found[0] if found else {} marketing = next((p for p in found if p.get("function") == "marketing"), {}) sales = next((p for p in found if p.get("function") == "sales"), {}) writer.writerow([ domain, len(found), top.get("name", ""), top.get("title", ""), marketing.get("name", ""), sales.get("name", ""), any(p.get("work_email") for p in found), ]) missing = [d for d in domains if d not in by_company] print(f"{len(people)} people at {len(by_company)} of {len(domains)} companies") if missing: print(f"No matching people published for {len(missing)} companies, e.g. {missing[:5]}") if __name__ == "__main__": main() 

The summary file answers the practical question for each account: is there a named person to address, and in which role? Companies with zero people are worth knowing about too. They usually have no team page, and that's your cue to use a different channel (the generic contact address, LinkedIn, an introduction).

Using the output well

  • Personalise from the title and page. "Chief Talent Officer" at an accounting firm tells you more than a database category would. Open the source_url before writing; team pages often have a short bio.
  • Prefer published channels. If work_email is empty, send to the company's published contact address and name the person, or connect on LinkedIn. The Contact Details Scraper returns the generic mailboxes, phones and company social pages for the same domains.
  • Re-run quarterly. Team pages change when people join and leave. A fresh run is cheaper than a bounced message to someone who left.

Limits

  • Best-effort detection. People are found with rules applied to the company's own pages, so verify each name and title before outreach. A title that names only an unfamiliar place (e.g. "General Manager, Kelowna") can be read as another organisation and left out.
  • Only published people. Companies without a team or leadership page return nothing (and aren't charged). People shown only in images, sliders or JavaScript widgets are missed, since there's no JavaScript rendering.
  • Title classification is keyword-based. Unusual titles fall back to staff seniority or the other function.
  • Coverage on large firms. Up to 25 pages per company at two requests per second per site, so firms with people spread across many pages can be partly covered.
  • Emails are rare. They're filled only when the page shows them with that person. Nothing is generated or pattern-matched.
  • LinkedIn and X profiles come only from the person's own card. A link two people share goes to neither. Board members, advisors and investors are always excluded.
  • robots.txt is always obeyed.
  • B2B use only. Contact people with relevant, individual messages, identify yourself, honour every opt-out, and follow the law that applies to you (CASL, CAN-SPAM, GDPR). Anyone can ask to be removed via the Actor's Issues tab; requests go on a suppression list within 7 days.

Why this tool

  • $0.90 per 1,000 decision-makers, below the leading decision-maker tools on the Apify Store.
  • Many domains per run, and only contacts the company published itself, each with its page URL: no pattern-guessed emails.

Cost

At the time of writing, the README lists $0.90 per 1,000 decision-makers on Apify's free plan and $0.70 per 1,000 on paid plans (Bronze, Silver, Gold), plus $0.002 per run. You pay per person returned after your seniority and function filters; companies with no matching people, unreachable or robots-blocked sites, and the pages read are not charged.

A list of 200 accounts that yields an average of four matching people each is 800 rows, about $0.72 on the free plan. Apify's free plan includes $5 of credit a month, which the README puts at about 5,500 decision-makers.

Links