DEV Community

Cover image for How I Built a 5-Stage B2B Lead Enrichment Waterfall in n8n & Python (75%+ Local SMB Coverage at ~$0.03/Lead)
Amitabh Mohan
Amitabh Mohan

Posted on Edited on

How I Built a 5-Stage B2B Lead Enrichment Waterfall in n8n & Python (75%+ Local SMB Coverage at ~$0.03/Lead)

If you have ever built a lead generation pipeline for local businesses, you already know the harsh reality: scraping mailto: links from homepages only gets you about 20% of SMB owners. The rest are buried behind contact forms, Cloudflare email obfuscation, generic info@ or hello@ front-desk inboxes, or barebones booking portals.

To fix this without paying $500+/month for a Clay + Apollo + NeverBounce stack, I built a self-healing n8n + Python/Apify 5-stage enrichment waterfall. To prove it works, I benchmarked it against a raw Google Maps export of 50 Med Spas in Austin, TX.

📊 Benchmark: 50 Austin Med Spas

Method Named Decision-Makers Verified Deliverable Emails Direct Executive Inboxes (first@) Ad Pixels & Tech Stack Est. Cost for 50 Leads
Basic Homepage mailto: Scraper 4 (8%) 11 (22%) 1 (2%) No ~$0.10
Static B2B Database (Apollo) 14 (28%) 16 (32%) 9 (18%) Enterprise Only ~$15.00+ (seat/credits)
Our 5-Stage Waterfall (n8n + Python) 31 (62%) 38 (76%) 23 (46%) 100% (Meta / Google / TikTok) ~$1.48 ($0.029/lead)

The Problem: Local SMBs Are Harder Than SaaS

Finding decision-makers in B2B SaaS is easy because tech founders live on LinkedIn and Crunchbase. Local SMBs—Med Spas, dental practices, roofing contractors, and boutique gyms—rarely maintain active company pages. Standard Google Maps scrapers only give you a front-desk phone number and a URL, leaving you to either pitch info@ or overpay for stale database credits.

Stage 1: High-Concurrency Async Subpage Crawl + Cloudflare Decryption

The first stage moves beyond the homepage. Using httpx and BeautifulSoup, the engine concurrently crawls high-signal internal links (/about, /our-team, /providers, /contact).

While scanning the HTML, it does two critical things:

Zero-Cost Ad-Pixel Detection: It checks script tags for Meta Pixel, Google Ads / GTM, and TikTok Pixel—automatically flagging businesses actively spending money on ads as High Intent (Running Ads).
Cloudflare Email De-Obfuscation: ~15% of local sites use Cloudflare's data-cfemail protection, hiding emails as [email protected]. I built a 6-line hex-XOR decoder (int(hex_str[i:i+2], 16) ^ int(hex_str[:2], 16)) to recover these hidden emails instantly at zero API cost.

Stage 2: Free US Federal NPI Registry API

When a healthcare, dental, or aesthetics clinic doesn't list its owner on the website, we tap into federal government data. Every US clinic with a medical director is indexed in the CMS National Plan and Provider Enumeration System (NPPES).

Passing a raw Google Maps name like "Light Touch Aesthetics" to the CMS NPI API often returns 0 results. The trick is to strip branch suffixes and append a trailing wildcard (*). This pulls the legal Authorized Official (Owner, President, or Medical Director) and their exact medical credentials (MD, NP, PA-C) for free.

import httpx

async def lookup_npi_official(clinic_name: str, city: str, state: str, client: httpx.AsyncClient) -> dict:
    # Strip Google Maps branch suffixes (e.g., "Viva Day Spa | Domain" -> "Viva Day Spa")
    clean_co = clinic_name.split("|")[0].split("-")[0].strip()

    params = {
        "version": "2.1",
        "organization_name": f"{clean_co}*",  # Wildcard catches "PLLC" / "LLC" differences
        "city": city,
        "state": state[:2].upper(),
        "limit": 5,
    }

    resp = await client.get("https://npiregistry.cms.hhs.gov/api/", params=params, timeout=5.0)

    if resp.status_code == 200:
        for res in resp.json().get("results") or []:
            basic = res.get("basic") or {}
            fn = (basic.get("authorized_official_first_name") or "").strip()
            ln = (basic.get("authorized_official_last_name") or "").strip()

            if fn and ln:
                cred = (basic.get("authorized_official_credential") or "").strip()
                role = (basic.get("authorized_official_title_or_position") or "Owner").strip().title()

                return {
                    "Decision_Maker": f"{fn.title()} {ln.title()}",
                    "Job_Title": f"{role} ({cred})" if cred else role,
                }

    return {"Decision_Maker": "N/A", "Job_Title": "N/A"}

Enter fullscreen mode Exit fullscreen mode

Stage 3 & 4: SERP X-Ray + Corporate Email Permutation

Finding a name like "Jorge Ibarra (Medical Spa Director)" alongside a generic info@lighttouchaustin.com inbox isn't enough for high-converting outbound.

The engine executes targeted search dorks (LinkedIn & Web SERP X-Ray) to verify the true decision-maker. Next, it generates executive permutations (jorge@, j.ibarra@, jorge.ibarra@) and verifies deliverability against a 9-provider pool—upgrading the generic front desk email into a Direct Executive Inbox.

Stage 5: 6 Data Hygiene "Battle Scars"

Scraping the data was only half the battle. During the initial Austin test runs, naive regexes and fallbacks produced massive hallucinations. Here are the 6 major bugs we had to squash to reach a 75%+ hit rate:

  • Battle Scar #1: Google's Chief Procurement Officer
    In raw Google Maps exports, listings without a website still have a url column (the google.com/maps/search/ link). My initial fallback (r.Website || r.website || r.url) saw that an Austin spa had no website, grabbed r.url, and enriched google.com—proudly reporting that a local Med Spa was run by Jennifer Moceri, Chief Procurement Officer at Google.

  • Battle Scar #2: The "Hydrafacial, MD" State-Code Hallucination
    When extracting City, State from page text, a standard 2-letter US state regex matched MD (Medical Doctor) as Maryland and IN (the preposition "in") as Indiana. That produced AI cold email openers like: "Saw your team is running campaigns in Hydrafacial, MD" and "in I also had, IN."

  • Battle Scar #3: Meet "Dr. Ling Recommends" (The Verb-as-a-Surname Trap)
    On a clinic's site, a heading read "Dr. Ling Recommends". My Dr. [First] [Last] regex captured "Ling Recommends" as the doctor's full name, classified her as the Clinical Owner, and permuted ling@. I had to build a human-name validator that blocks verbs, UI words, and 1-letter last initials.

  • Battle Scar #4: Apostrophe & Compound-Word Blindness
    When normalizing company names for LinkedIn verification, replacing non-alphanumeric characters turned "It's A Secret Med Spa" into "it s a secret med spa", failing 100% of word-boundary checks. Similarly, "Medspa of Austin" returned zero LinkedIn hits until I built an alias generator expanding medspa to med spa.

  • Battle Scar #5: n8n’s A1:Z2 Empty-Cell Header Shift
    In n8n’s native Google Sheets node, reading A1:Z2 uses Row 1 as keys and Row 2 as values. It silently drops any column whose cell in Row 2 is empty. A single blank cell in a client's sheet shifted all headers to the left, corrupting the database. I had to bypass the native node entirely with a raw GET !1:1 HTTP Request to self-heal the schema.

  • Battle Scar #6: Multi-Location Chain Fan-Out
    Local chains (like Viva Day Spa) had multiple Austin branches in the same Google Sheet sharing one root domain. Deduplicating by domain meant only the first branch got updated. I engineered a sheetLeadMap[domain] fan-out array in the n8n JavaScript node so one enrichment API call automatically populates every branch row in the sheet at zero extra cost.

Open source n8n workflow & Actor

Top comments (0)