All articles
Build Guides

Build a Lead-Enrichment Agent That Scrapes Domains with Playwright & Gemini

FDE Coach EditorialAugust 28, 20268 min read

What We're Building

A headless lead-enrichment agent. You drop in a CSV of company domains, the agent spins up a headless Chromium instance via Playwright, scrapes the homepage and core pages, extracts clean text, and ships it off to Google Gemini’s free tier. Gemini returns structured JSON with the company’s description, industry, estimated size, and key value props.

Core features:

  • Batch domain ingestion from a plain CSV.
  • Playwright-based intelligent scraping that handles SPAs, cookie banners, and redirects.
  • Gemini Flash free-tier integration for structured extraction.
  • Structured JSON output per domain, ready to pipe into a CRM.
  • Respectful rate limiting so you don’t get IP-banned mid-run.

This isn’t a toy. It’s a production pattern you can bolt onto Clay, n8n, or a custom internal tool. By the end, you’ll have a single Python script that turns a list of URLs into a lead sheet you’d actually pay for.

Architecture Flow

Prerequisites (All Free Tier)

  • Python 3.10+python.org/downloads
  • Google Gemini API Key – Grab one from aistudio.google.com/apikey. The free tier gives you 15 requests per minute on Gemini 1.5 Flash, which is plenty for this pipeline.
  • Playwright – Open-source browser automation. We’ll install it via pip and its bundled Chromium.

No credit card needed. No hidden quotas that’ll surprise you.

Step 1: Project Setup

mkdir lead-agent && cd lead-agent
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install playwright google-generativeai
playwright install chromium

Create a .env file:

GEMINI_API_KEY=your_key_here

Create domains.csv:

domain
stripe.com
vercel.com
linear.app

Step 2: Scraping with Playwright

We want a browser that loads the page, dismisses cookie banners, and waits for content to settle. Here’s the scraper module:

# scraper.py
from playwright.sync_api import sync_playwright
import time

def scrape_domain(domain: str) -> str:
    """Returns visible text content from a domain's homepage."""
    url = f"https://{domain}" if not domain.startswith("http") else domain

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        )
        page = context.new_page()

        try:
            page.goto(url, wait_until="domcontentloaded", timeout=15000)
            # Let JS-heavy pages settle
            page.wait_for_timeout(2000)

            # Aggressive cookie banner dismissal
            common_selectors = [
                "button:has-text('Accept')",
                "button:has-text('Accept All')",
                "button:has-text('OK')",
                ".cookie-accept",
                "#onetrust-accept-btn-handler"
            ]
            for selector in common_selectors:
                try:
                    page.click(selector, timeout=1000)
                    break
                except:
                    continue

            # Grab visible text from body, stripping scripts/styles
            text = page.inner_text("body")
            return text[:8000]  # Gemini Flash can handle this easily

        except Exception as e:
            return f"SCRAPE_ERROR: {str(e)}"
        finally:
            browser.close()

Why inner_text and not full HTML? LLMs choke on raw DOM. Visible text is higher signal, lower token count, and free-tier-friendly.

Step 3: Extracting Text Content

The scraper already returns clean-ish text. But you’ll hit pages with massive nav bars, footer links, and legal disclaimers that add noise. A production-grade version would strip boilerplate with trafilatura or readability-lxml, but for the free-tier pipeline, the 8000-character cap + Gemini’s native reasoning handles this well enough.

Step 4: Prompt Engineering for Gemini

This is where most builders phone it in. A lazy prompt gets hallucinated industries and made-up employee counts. A tight prompt with explicit output schema gets CRM-ready data.

# gemini_client.py
import google.generativeai as genai
import os
import json

API_KEY = os.getenv("GEMINI_API_KEY")
genai.configure(api_key=API_KEY)
model = genai.GenerativeModel("gemini-1.5-flash")

def extract_lead_profile(domain: str, scraped_text: str) -> dict:
    prompt = f"""You are a lead enrichment analyst. Given the scraped text from {domain}, extract a structured company profile.

Return ONLY valid JSON with these keys:
- company_name: string
- description: 2-sentence summary of what they do
- industry: string (pick from: Fintech, SaaS, E-commerce, Healthcare, AI/ML, DevTools, Cybersecurity, Other)
- estimated_size: string (pick from: 1-10, 11-50, 51-200, 201-1000, 1000+)
- key_products: list of strings (max 3)
- target_customer: string (who they sell to)
- confidence: string (High/Medium/Low based on text quality)

Scraped text:
{scraped_text[:6000]}"""

    try:
        response = model.generate_content(prompt)
        # Gemini sometimes wraps JSON in markdown fences
        raw = response.text.strip()
        if raw.startswith("```"):
            raw = raw.split("```")[1]
            if raw.startswith("json"):
                raw = raw[4:]
        return json.loads(raw)
    except Exception as e:
        return {"error": str(e), "domain": domain}

Why it works: We constrain the output schema explicitly. We cap text at 6000 characters to leave room for the prompt. We handle Gemini’s occasional markdown-wrapping quirk. The industry and size enums prevent free-form chaos.

Step 5: The Lead-Enrichment Pipeline

Now wire everything together with rate limiting and result persistence:

# main.py
import csv
import json
import time
import os
from dotenv import load_dotenv
from scraper import scrape_domain
from gemini_client import extract_lead_profile

load_dotenv()

def process_domains(csv_path="domains.csv", output_path="enriched_leads.json"):
    with open(csv_path, "r") as f:
        reader = csv.DictReader(f)
        domains = [row["domain"].strip() for row in reader if row.get("domain")]

    results = []
    for i, domain in enumerate(domains):
        print(f"[{i+1}/{len(domains)}] Processing {domain}...")

        # Scrape
        text = scrape_domain(domain)
        if text.startswith("SCRAPE_ERROR"):
            results.append({"domain": domain, "error": text})
            continue

        # Enrich
        profile = extract_lead_profile(domain, text)
        profile["domain"] = domain
        results.append(profile)

        # Respect Gemini free tier: 15 RPM = 1 request per 4 seconds
        time.sleep(5)

    with open(output_path, "w") as f:
        json.dump(results, f, indent=2)

    print(f"Done. {len(results)} leads written to {output_path}")

if __name__ == "__main__":
    process_domains()

Step 6: Running the Agent

python main.py

You’ll see Playwright spin up headless Chromium, scrape each domain, and Gemini return structured profiles. Output lands in enriched_leads.json:

[
  {
    "domain": "stripe.com",
    "company_name": "Stripe",
    "description": "Stripe provides payment processing infrastructure for internet businesses. Their APIs enable companies to accept payments, manage subscriptions, and handle complex billing workflows.",
    "industry": "Fintech",
    "estimated_size": "1000+",
    "key_products": ["Stripe Payments", "Stripe Connect", "Stripe Billing"],
    "target_customer": "Online businesses, platforms, and marketplaces",
    "confidence": "High"
  }
]

Sensible Extensions

Multi-page scraping. The current version only hits the homepage. For richer profiles, follow the “About” and “Pricing” links. Use Playwright’s page.locator('a:has-text("About")').click() pattern, but add a depth limit (max 3 pages) to avoid spidering the entire site.

CRM webhook. Pipe results directly to HubSpot or Salesforce. Add a requests.post(WEBHOOK_URL, json=profile) call after each enrichment. Free-tier HubSpot forms accept JSON payloads with no authentication for testing.

Confidence-based filtering. Only push leads with confidence: "High" to your CRM. Flag Low results for manual review.

Parallelism. The free tier’s 15 RPM cap makes parallel scraping pointless for Gemini calls, but you can scrape domains concurrently with Playwright’s async API while queuing enrichment requests.

If you enjoy stitching agents like this into larger workflows, check out our guide on Build a YouTube-to-Blog Repurposing Agent with Gemini Free Tier and n8n — same pattern, different data source.

Common Pitfalls & Debugging

“SCRAPE_ERROR: net::ERR_NAME_NOT_RESOLVED” — The domain doesn’t resolve. Check for typos, add www. prefix handling, or skip and log.

Gemini returns ”error”: “Resource has been exhausted” — You hit the free-tier rate limit. Increase time.sleep() to 6-8 seconds or check your quota at aistudio.google.com.

JSON parse errors — Gemini occasionally returns malformed JSON. Add a retry loop with exponential backoff. In production, use a JSON repair library like json-repair.

Playwright timeout on SPAs — Some React apps take forever to hydrate. Increase wait_until="networkidle" and bump the timeout to 30 seconds for problematic domains.

Cookie banners still blocking content — The selector list covers 80% of banners. For stubborn ones, add domain-specific selectors or use Playwright’s page.evaluate() to remove overlay elements by z-index.

FAQ

Q: Is Gemini Flash actually free? Yes. Google’s free tier gives you 15 RPM and 1,500 requests per day on Gemini 1.5 Flash as of writing. That’s 1,500 lead profiles per day without spending a cent.

Q: Can I scrape 10,000 domains with this? You can, but you’ll need to batch across multiple days (free tier daily cap) or upgrade to pay-as-you-go. The architecture stays the same — just swap the API key.

Q: What if a site blocks Playwright? Rotate user agents, add --disable-blink-features=AutomationControlled to launch args, or route through residential proxies. For a deeper dive on agent architecture patterns, read Agentic Context Management: Treating Memory as an Architecture Problem.

Q: How does this compare to Clay or Apollo? Clay and Apollo are polished products with built-in waterfall enrichment. This agent is the free, hackable alternative. You control the logic, the prompts, and the output schema. When you need something custom — like extracting a very specific signal from a pricing page — this pattern wins.

Q: Can I deploy this as an API? Absolutely. Wrap process_domains in a FastAPI endpoint, add a background task queue, and you’ve got an internal enrichment microservice. If you’re building internal tools like this, you might also like Build a Daily Standup Bot That Collects Updates via DM and Posts a Slack Summary with Gemini.

Q: What’s the next skill to level up after this? Mastering structured extraction with LLMs is a core Forward Deployed Engineer skill. Once you’re comfortable with single-shot prompts like this, the next frontier is agentic loops where the model decides which pages to scrape and what questions to ask. FDE Coach has deep-dive resources on this exact progression — no bootcamp fluff, just patterns that ship.

#lead-enrichment#web-scraping#gemini#playwright

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now