Detecting Chain Ownership on US Clinic Websites with Python
Suppose you build a lead list for a team selling software or supplies to independent US dental, veterinary, audiology, or optometry clinics. Map listings return neighborhood names like Oakridge Family Dentistry or Suncoa
Suppose you build a lead list for a team selling software or supplies to independent US dental, veterinary, audiology, or optometry clinics. Map listings return neighborhood names like Oakridge Family Dentistry or Suncoast Pet Hospital. The list looks full of single-office owners.
Once reps start calling, many of those offices turn out to belong to corporate groups, including Dental Support Organizations (DSOs) like Heartland Dental and Aspen Dental or veterinary networks like Banfield and VCA. An acquired clinic can keep its local domain while a parent company controls purchasing. Reps end up pitching staff who cannot approve new vendors.
To filter clinic lists before outreach, I built an open-source Apify Actor, apify-us-healthcare-clinic-contact-enricher. It fetches public clinic websites in async Python, pulls contact details and Schema.org metadata, and checks sites against 38 US chains. Static HTML checks are all the live actor runs today. Querying the NPPES NPI registry and scanning for booking widgets are separate ideas for a later version.
Scraping contacts and chain clues from static HTML
I wrote the actor with httpx, BeautifulSoup4, and dnspython so it runs without a headless browser. For each URL, the crawler fetches the homepage and up to two internal contact, about, locations, or team pages. Skipping browser rendering saves memory, though sites that render contacts with JavaScript return fewer fields.
The parser collects emails from HTML and mailto: links while dropping asset filenames, sentry.io, wixpress.com, and placeholder domains. Next, dnspython checks whether each domain has an MX record (or an A record if no MX exists) for mx_dns_valid_emails. That DNS check covers the domain without verifying individual mailboxes. Alongside emails, the script formats US phone numbers from tel: links and text as +1-XXX-XXX-XXXX, collects up to three links each for Facebook, Instagram, LinkedIn, YouTube, and X/Twitter, and tags a rough specialty category from keywords.
For ownership, the actor computes a chain_confidence_score from four heuristics. First, it matches the domain and HTML against a hand-written list of 38 US chains across dental DSOs, hearing-aid retailers, veterinary groups, and chiropractic, PT, urgent-care, and optical brands (such as Heartland Dental, Aspen Dental, Miracle-Ear, Banfield, VCA, and MyEyeDr), plus terms you pass in customChainKeywords. Second, it reads Schema.org JSON-LD blocks (LocalBusiness, MedicalOrganization) and raises the score if parentOrganization appears. Third, it checks page text for franchise wording such as "each office is independently owned and operated". Fourth, five or more internal store-locator links raise the score, and 15 or more push it to chain status.
Scores of 0.80 or higher return Chain Excluded, 0.45 to 0.80 return Needs Review, and lower scores return Likely Independent. That last label means none of the four signals matched. Sites that fail to load or return HTTP 4xx or 5xx responses come back as Needs Review and log the failure in error.
Idea 1: checking organizational records in the public NPPES NPI API
A fixed list of 38 chains has clear limits. Regional groups outside that list, or acquired clinics that keep a local domain and omit parent names from their HTML, come back as Likely Independent. Checking federal provider records is an idea I want to test next; the actor does not include it today.
CMS runs a free public NPPES NPI Registry API at https://npiregistry.cms.hhs.gov/api/ (version 2.1) that needs no API key. NPI-1 records cover individuals, while NPI-2 records cover organizations. An NPI-2 record includes the organization name, LOCATION and MAILING addresses, and an authorized_official block with the official's name and title. This async sketch (its query parameters and field names match a live call I made to the API on 5 Nov 2026) queries NPI-2 records and extracts the authorized official:
import httpx
NPPES_API_URL = "https://npiregistry.cms.hhs.gov/api/"
async def lookup_clinic_npi(
client: httpx.AsyncClient, organization_name: str, city: str, state: str
) -> dict:
params = {
"version": "2.1",
"enumeration_type": "NPI-2",
"organization_name": f"{organization_name}*",
"city": city,
"state": state,
"limit": 5,
}
resp = await client.get(NPPES_API_URL, params=params, timeout=8.0)
resp.raise_for_status()
results = resp.json().get("results") or []
if not results:
return {}
basic = results[0].get("basic") or {}
return {
"npi_number": results[0].get("number"),
"legal_business_name": basic.get("organization_name"),
"authorized_official_first_name": basic.get("authorized_official_first_name"),
"authorized_official_last_name": basic.get("authorized_official_last_name"),
"authorized_official_title": basic.get("authorized_official_title_or_position"),
}
Using this in a batch pipeline requires careful matching. Clinics display local trade names on their websites while registering NPI-2 records under legal entity names, so name-and-city queries can miss or misalign offices. Cross-checking phone numbers and street addresses from the website against the LOCATION entry in results[0]["addresses"] should cut false matches, while comparing LOCATION and MAILING states could flag out-of-state billing hubs.
Idea 2: detecting booking widgets in the fetched HTML
Scanning that same HTML for online booking scripts is a second idea outside the current actor. Teams selling scheduling or messaging software want to know whether a clinic embeds NexHealth, LocalMed, Weave, or Zocdoc. A dictionary of compiled regex patterns can spot those vendor domains during the crawl:
import re
BOOKING_STACK_SIGNATURES: dict[str, re.Pattern[str]] = {
"NexHealth": re.compile(r"nexhealth\.com|nh-widget", re.I),
"LocalMed": re.compile(r"localmed\.com", re.I),
"Weave": re.compile(r"getweave\.com|weavehelp\.com", re.I),
"Dentrix Ascend": re.compile(r"dentrixascend\.com", re.I),
"Open Dental": re.compile(r"opendental\.com|patientviewer\.com", re.I),
"Zocdoc": re.compile(r"zocdoc\.com", re.I),
}
detected_stack = [
vendor for vendor, pat in BOOKING_STACK_SIGNATURES.items() if pat.search(combined_html)
]
Static HTML limits this check in the same way it limits contact extraction. Clinics that load booking modals at runtime through Google Tag Manager hide the vendor URL from httpx, so an empty list means no widget appeared in the initial HTML.
What the Actor does today / what I'd add next
You can run the current build on Apify Store or read the code on GitHub. On the Apify free tier the actor caps each run at 25 URLs. Today the actor crawls static HTML across each homepage and up to two internal pages, returning emails, domain MX/A checks, normalized US phones, social links, specialty tags, and Schema.org JSON-LD metadata. Its chain check combines the 38-chain US list, customChainKeywords, JSON-LD parentOrganization fields, franchise wording, and store-locator link counts. You can trigger runs from Python with apify-client or from n8n and Make.com.
The live actor skips headless browsers, misses groups outside its 38-chain list, and includes neither NPPES NPI lookups nor booking-widget checks. Next, I want to add an optional NPPES NPI-2 step that joins clinics to CMS records by phone number and street address to pull authorized_official fields, and then scans the fetched HTML for booking widgets.
Joining scraped local websites to public registries tends to surface odd edge cases across states. Have you matched healthcare or local-business leads against NPPES records, and did phone numbers or street addresses give you cleaner joins than clinic names?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.