Batch Contact Email
What it does
Pure email lookup for an already-vetted domain list: no classification or filtering step, so it's faster than pairing classify with a filter. For each domain, tries a small set of contact-page paths first (free, plain scraping), and only calls a paid email-lookup API when scraping genuinely finds nothing. Every input domain appears in the output, with the found email or / if none was found anywhere; never a silent drop.
Requirements
- Web fetch tools to scrape contact pages. Free option: Claude Code's built-in WebFetch, or Scrapling (
pip install "scrapling[fetchers]") for JS-rendered or Cloudflare-protected sites; verified 2026-09-08 from the project README. - Subagents for parallelizing large batches (optional). Free option: Claude Code's Agent tool; without it, run every domain sequentially inline in the same session.
- Email discovery fallback (optional, used only when scraping fails): the Hunter.io API (
https://api.hunter.io/v2/domain-search). Free option: Hunter's free tier (50 credits/month, 1 credit per Domain Search call returning a result), verified 2026-09-08 from Hunter's pricing page; or skip Hunter entirely and rely on scrapedmailto:links and contact pages only. - Google Sheets output (optional): any Sheets MCP server (e.g. xing5/mcp-google-sheets), or the gspread Python package (
pip install gspread); verified 2026-09-08, current PyPI release 6.2.1.
Inputs and outputs
| Input | A domain list (one per line, with or without scheme) and an output preference: CSV (default), a markdown table (batches under 30), or Google Sheets |
| Output | ./output/contact-emails-<YYYYMMDD-HHMM>.csv with columns Domain, Email (/ where nothing was found), or the same rows inline as a markdown table for small batches |
Worked example
Paste into Claude Code with this skill installed:
/seo-ops:batch-contact-email find contact emails for these domains:
example.com
example.org
example.net
Expected: ./output/contact-emails-20260908-1400.csv with one row per domain (example.com, hello@example.com) plus a summary line: 3 domains -> 2 emails found (67% hit rate).
Procedure
Parse input. Accept the domain list, one per line, with or without a scheme. Strip schemes, normalize, deduplicate. Decide output format: CSV by default, a Google Sheet if the caller asks for one or supplies a sheet URL, or an inline markdown table for small batches (under 30 domains).
Per-domain email-finding method. For each domain, follow this waterfall, returning on the first hit:
- Fetch common contact paths, in this order, stopping at the first 200-OK page with at least one email match:
/contact,/contact-us,/about,/about-us, then the homepage (last resort; emails often sit in the footer). - Regex the page text for
[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}, plus obfuscated forms (name [at] domain [dot] com,name (at) domain (dot) com) andmailto:links. - Reject decoys:
example@,your.email@,name@example.com, addresses embedded in an imagesrc="...", and local-parts ending in more than 5 digits (usually a CDN cache filename, not a real address). - Pick the best match when several emails are found: prefer a same-domain address over a free-mail one, prefer editorial roles (
editor@,editorial@,tips@,pitch@) for media sites, preferinfo@/contact@/hello@for SaaS or services, and avoidnoreply@/no-reply@/support+billing@. - Fall back to Hunter (see Requirements) only when scraping genuinely fails: every contact path blocked or 404, regex finds nothing, or every match was rejected as a decoy. Take the most generic email returned, in priority order
info@,contact@,hello@,editor@. On a Hunter rate-limit or auth error, skip it; that domain yields/. - Emit
{"domain","email"}for the domain (email= the address found, or/). Never omit a domain.
- Fetch common contact paths, in this order, stopping at the first 200-OK page with at least one email match:
Parallelize larger batches. For anything beyond a handful of domains, dispatch chunks of domains to subagents in parallel rather than working through the list sequentially in the main session: one message dispatching every chunk at once, not a few at a time. A useful pattern for a worker subagent here: give it a restricted tool set (fetch/search tools plus the ability to write its results to a file, nothing else; specifically no ability to spawn further subagents or message other agents) so a worker can't nest-dispatch or drift outside its one job. Pick a
run_idfor the batch (e.g.bce-<YYYYMMDD-HHMM>) and have each worker write its rows to./runs/<run_id>/chunk-<i>.jsonl(one{"domain","email"}object per line) rather than returning them as prose, since prose responses fragment across many parallel workers and are unreliable to reassemble.Chunk sizing (target: 12 domains/worker):
Domain count Chunking 1-12 1 worker, no chunking 13-100 ceil(N/12)workers, 12 domains/chunk101-500 parallel; prefer 15-20 domains/chunk if worker throughput caps out >500 ask the caller to split the list; don't fan out past this size in one run
Hard cap: 8 concurrent workers in flight. If ceil(N/12) would exceed 8, grow the chunk size instead of the worker count (ceil(N/8) domains per chunk, 8 chunks); a much higher concurrent-worker count has been observed to exhaust local browser-launch limits and produce hard failures on some fetch backends; if you hit that, lower the worker count and grow chunks instead. If you are already running as a subagent, nested dispatch is usually unavailable; skip the fan-out and process every chunk sequentially inline using the same waterfall.
Merge the chunk files. Read every
./runs/<run_id>/chunk-*.jsonl, one JSON object per line, and merge by domain (last write wins on a duplicate):python3 - <<'PY' import json, glob rows = {} for f in sorted(glob.glob("./runs/<run_id>/chunk-*.jsonl")): for line in open(f, encoding="utf-8", errors="replace"): line = line.strip() if not line: continue try: o = json.loads(line) except Exception: continue rows[o["domain"]] = o.get("email", "/") print(f"merged {len(rows)} domains") PYIf the merged row count doesn't match the input domain count, diff input vs. merged, re-dispatch only the missing domains to one more worker (or run them inline), and re-merge. Every input domain must appear before building the output.
Build the output.
- CSV (default): write
./output/contact-emails-<YYYYMMDD-HHMM>.csv, columnsDomain, Email, and tell the user the file path. - Google Sheet: write the CSV locally first, then push it to a sheet via a Sheets MCP server or
gspread(see Requirements); create a new sheet, or write into a caller-supplied sheet URL if given. - Markdown table (batches under 30): reply inline in chat instead of writing a file.
- CSV (default): write
Summary. Reply to the user in chat:
<N_in> domains -> <N_with_email> emails found (<hit_rate>% hit rate).A hit rate under 60% usually means anti-bot protection is blocking most contact pages, or the email-lookup fallback is out of quota; note that in the summary when the batch consistently scores low.