Skip to content

Autonomous leadgen pipeline (eval-gated) - #39

Open
mikececco wants to merge 14 commits into
masterfrom
find-seo-agencies-to-partner
Open

Autonomous leadgen pipeline (eval-gated)#39
mikececco wants to merge 14 commits into
masterfrom
find-seo-agencies-to-partner

Conversation

@mikececco

Copy link
Copy Markdown
Owner

Adds the Italian agency lead-gen pipeline with an autonomous eval gate.

  • leadgen/evals/eval_leads.py — 20-check data-quality suite (blocks merge on fail)
  • .github/workflows/leadgen-evals.yml — runs the eval on every PR + weekly health check
  • hardened clean_csv.py / reproducible build_segments.py / run_pipeline.sh

Data: 1,228 verified emails, 20/20 evals pass locally. This PR run proves the gate in CI.

⚠️ Contains personal emails — repo must stay private; merge to master is a human decision.

🤖 Generated with Claude Code

mikececco and others added 2 commits July 12, 2026 12:22
1,420 sourced domains → 1,248 verified emails → 85 personal leads +
LinkedIn/email outreach kits, plus the reusable harvest/clean/enrich/
segment scripts. Private data — see leadgen/README.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- leadgen/evals/eval_leads.py: 20-check data-quality gate (exit 1 blocks merge)
- clean_csv.py: strict email sanitization (drops malformed/URL-encoded/junk)
- build_segments.py: reproducible via committed domain_angles.json (no journal dep)
- run_pipeline.sh: deterministic orchestrator ending in the eval gate
- .github/workflows/leadgen-evals.yml: eval on PR + weekly health check

Eval caught 6 malformed addresses manual review missed; data now 1,228 verified,
20/20 checks pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

@mikececco is attempting to deploy a commit to the SUPALABS DEV's projects team on Vercel, but is not a member of this team. To resolve this issue, you can:

  • Make your repository public. Collaboration is free for open source and public repositories.
  • Upgrade to pro and add @mikececco as a member. A Pro subscription is required to access Vercel's collaborative features.
    • If you're the owner of the team, click here to upgrade and add @mikececco as a member.
    • If you're the user who initiated this build request, click here to request access.
    • If you're already a member of the SUPALABS DEV's projects team, make sure that your Vercel account is connected to your GitHub account.

To read more about collaboration on Vercel, click here.

mikececco and others added 8 commits July 12, 2026 16:47
- leadgen-refresh.yml: weekly re-harvest + eval-gated auto-commit (gate inside the
  job, works on free plan — commit step is skipped if the eval step fails)
- leadgen/SOURCING_ROUTINE.md: spec the weekly Claude cloud routine executes
  (source new agencies -> pipeline -> eval-gated PR), with GDPR/dedup/cap guardrails

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- audit_list.py: business-readable list health report
- linkedin_presence.py: crawl agency sites for linkedin.com links
- linkedin_presence.csv: 581/852 agencies have a LinkedIn (510 company URLs)
- personal_leads_linkedin.csv: 84 priority leads + LinkedIn company page (55 matched)

Eval gate: 20/20 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 1,096 new agencies sourced across 24 countries (workflow fan-out)
- international_leads.csv: country · agency · domain · email · LinkedIn
  → 629 with email, 683 with LinkedIn, 520 with BOTH
- country_map.json (domain→country), domains_intl.txt (intl seed, kept
  separate from the Italy pipeline so its eval + Italian kits stay clean)
- harvest_emails.py / linkedin_presence.py parametrized (SEED/OUT args)

Italy eval unchanged: 20/20 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
icp_shortlist.csv: 113 agencies positioned around SEO/GEO AND present on LinkedIn.
- Tier 1 GEO/AI-forward: 22 (18 with email) — sharpest ICP (sell GEO/AEO/AI-search)
- Tier 2 SEO-positioned: 91 (69 with email)
Signal = agency's own name/domain positioning + Italy verified GEO vertical (reliable),
not the noisy sourcing angle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zed)

Competitor-teardown content targeting agencies researching Outrank, positioning
AltoRank as the client-safe GEO/AEO alternative. Serves the "outrank alternative"
hero keyword + P1 pillar.
- outrank-backlink-exchange-article.md: full ~2,100w piece, AI-citation ~90/100
  (question H2s, DR-vs-GEO table, risk framework, Ahrefs/AirOps stats, FAQ)
- outrank-backlink-exchange.mdx: Astro-ready + BlogPosting/FAQPage JSON-LD
- outrank-linkedin-post.md: EN+IT companion to warm the 22 GEO-forward agencies
TODO before publish: real named author, verify competitor claims, link stat sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds src/content/blog/outrank-backlink-exchange.mdx (category: agencies) with FAQ
frontmatter (→ FAQPage schema via layout). @astrojs/sitemap auto-includes it — blog
is not in the sitemap exclude filter, so it's sitemap-ready on build.

Goes live on merge to master + deploy. TODO before merge: verify competitor claims,
optionally swap author to a named person, add ogImage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…utine

- OUTREACH_PLAYBOOK.md: discovery-email rules — exploration ONLY (never sell/mention
  product), one true specific detail per agency (skip if none), single CTA = 20-min
  call, 80-120 words, with gold-standard IT examples.
- SOURCING_ROUTINE.md step 5: routine drafts ~12 highly-personalized exploration
  emails/week into leadgen/weekly_drafts_<date>.md (files only, no Gmail send).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the pre-send validation and enrichment layer the list was missing.

mx_recheck.py — MX-valid at harvest time is not MX-valid at send time. Re-checks
every domain and gates on decay (currently 1.8%: 15 dead domains, 20 rows to prune).

enrich_contacts.py — 814 of 852 agencies had no human attached, only info@. Pulls a
first name from the LinkedIn profile slug already in linkedin_presence.csv, lifting
addressable-by-name from 38 to 95 agencies. Deliberately does NOT synthesize
addresses: guessing marco@agency.it would inflate the bounce rate this branch exists
to protect, and is a worse GDPR position than mailing a published role inbox. Every
address emitted already exists in brevo_import.csv, and an eval now enforces that.

geo_signal.py — build_icp_shortlist.py tiers agencies from the domain string alone,
which is why only 113 of 852 qualified and 792 sat in "General/Web". This reads what
each agency actually says it does, scored per page type because placement carries
meaning: 68 sell GEO on a services page, 91 position on it via the homepage, 57 only
blog about it, and 324 are strong-SEO/zero-GEO. 216 show GEO language versus the 12
the name-based tagging found. tier_agency() is intentionally left unimplemented —
turning this evidence into tiers is a business call, not a parsing one.

names.py — FIRST_NAMES/GENERIC extracted from enrich_named.py so both consumers score
against one list. Expanded with ~60 missing common names, which also lifted
enrich_named from 84 to 89 truly-personal contacts.

eval_leads.py — 8 new checks (20 -> 28): MX decay ceiling, zero invented addresses,
one row per agency, greeting/name agreement, and no GEO score from an unreachable site.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Hobby accounts are limited to daily cron jobs. This cron expression (*/15 * * * *) would run more than once per day. Upgrade to the Pro plan to unlock all Cron Jobs features on Vercel.

Learn More: https://vercel.link/3Fpeeb1

mikececco and others added 4 commits August 1, 2026 12:18
domains.txt held 1,420 agencies but only 854 ever produced an email — 566 were
sourced, qualified, and then silently dropped. Sampling 40 of them showed why: only
~1 in 40 has a recoverable address, the rest publish a contact form only. So they are
not an email-harvest problem, they are an email-unreachable population.

Rerunning the existing linkedin_presence.py against that set (it already takes a seed
and output path, no new code) finds 161 of 566 on LinkedIn — 131 company pages, 35
people profiles, 18 with an extractable first name. That lifts reachable agencies from
852 to ~1,005 without sourcing anything new.

domains_untapped.txt records the derivation so the set can be rebuilt.

Also adds an eval check for a hazard this surfaced: 15 LinkedIn company pages are
shared by more than one domain (seocube.it/seocubeagency.it, eskimoz.it/eskimoz.co.uk,
deda.digital across three). Eight span the emailable and untapped sets, so without a
dedupe those agencies get an email AND a DM from us.

Co-Authored-By: Claude <noreply@anthropic.com>
…ss domain

mx_recheck.py was checking MX for the agency's WEBSITE domain rather than the domain
of the address we would actually mail. Those differ on 16% of rows (200 of 1228) —
pierpaolo@inyourmindweb.it is listed under DOMAIN 0-0-0.it — so the gate was verifying
the wrong thing for one row in six. Now keyed on the address, and the eval join with
it. It also takes an optional source/output path so the same gate covers the
international list.

The corrected check immediately surfaced what the old one structurally could not:
eight WordPress theme-demo addresses sitting in the send list (support@themesflat.com,
brooklyn@wp-brooklyn.com, emeline@qode.com, info@fuel-lab-dev.local, info@company.it,
hq@thewebsite.com). Their agency domains resolve fine, so checking the website domain
always passed them. Added to clean_csv.py's BAD_DOMAINS, plus a suffix rule for
.local/.test/.invalid hostnames that can never receive mail. 1228 -> 1220 emails.

build_all_leads.py unions Italy + international into all_leads.csv: 1,789 agencies
(1,008 Italy, 781 international across 12 locales), 1,462 email-reachable and 327
LinkedIn-only. Built as a union on top rather than by mutating brevo_import.csv /
leads_segmented.csv, because the eval floors and the Italian outreach kit are
calibrated to those files.

GREETING is deliberately left empty for non-`it` locales and an eval enforces it —
only Italian copy is approved, and a merged list makes it far too easy to send it to
Warsaw. LinkedIn-only rows now get a name off the profile slug too, since that is
where a name matters most; named contacts 93 -> 114.

Co-Authored-By: Claude <noreply@anthropic.com>
Defines "look-alike" from the data rather than by guessing: the proven ICP is an
agency that publicly positions on GEO/AEO/AI-search, so six parallel searches went
after exactly that across DE/AT, UK/IE, FR/BE, NL, ES/PT and PL/Nordics using
local-language queries. 337 candidates, 144 already in the pipeline (a 43% rediscovery
rate, which is a decent check on both the existing list and the search), 193 new.

The targeting worked: 133 of 139 reachable new agencies (96%) use GEO language and 66
sell it on a services page — against 26% and 8% for the Italian list.

geo_signal.py is now multilingual (GEO/SEO terms and services-page slugs for DE, FR,
ES/PT, NL, PL and the Nordics) and takes a source/output path, so it could score the
1,096 pre-existing international agencies too. That result reframes the geography:
621 of 931 reachable international agencies (67%) use GEO language and 268 sell it —
roughly 3.5x the density of the Italian list.

Email hygiene had been Italian-only and did not survive contact with 16 countries.
kontakt@, hallo@, hola@, biuro@, post@ and office@ were all being counted as named
human contacts, and prenom@/naam@/din@/exemple@ are theme placeholders — the first
pass reported 138 "named" where there were 45. harvest_emails.py now shares the
role/placeholder/short-name vocabulary in names.py, treats <=3-char local-parts as
office codes unless they are real names (muc@ bln@ col@ dd@ are cities, not people),
and applies the placeholder-domain list that previously only clean_csv.py enforced —
directly harvested files never pass through clean_csv, which is how you@company.com
and luna@monentreprise.be reached the send list. The eval caught both.

all_leads.csv: 1,946 agencies (1,008 Italy, 938 international across 12 locales),
1,600 email + 346 LinkedIn. Italy pipeline files unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
…encies

Everything upstream of this is a signal. This is the checkpoint that decides whether a
row is worth a human's time: the 284 highest-intent rows in all_leads.csv are
re-fetched live and must clear four checks — the site responds today, it sells
services rather than software, the GEO/AEO claim is still on the page (quoted as
proof), and there is a live-MX address or a LinkedIn page.

Result: 274 verified, 133 of which sell GEO on a services page. 222 have LinkedIn as a
second channel. Italy 152, rest of Europe 122.

Two things this got wrong first, both worth recording:

The tool filter originally demanded proof that a site was an agency, and rejected five
real ones whose sites simply do not use an English or Italian word for it —
citationlab.no says "AEO-byrå", iaba.tech "Agence GEO", aeovara.fi "autamme sinua".
Same failure mode as the role-inbox vocabulary last commit. It now rejects only on
POSITIVE evidence of a product (trial/pricing/app subdomain outweighing agency
signals), because absence of vocabulary I happened to enumerate is not evidence.
Spot-checked against the domains most likely to be tools: llmo-check.de (19x
"Agentur"), sikte.ai (9x "byrå") and generatemore.ai are all genuinely agencies.

It also reported "GEO claim no longer on site" for 41 agencies while never fetching a
blog — which is exactly where those agencies discuss GEO. Blog pages are now in the
fetch list and the count fell to 9, confirming the data was fine and the verifier was
not. The rejection reason is renamed to what it actually tests.

VERDICT deliberately means "worth contacting", NOT "mailbox exists" — port 25 is
blocked here, so this still needs a bounce verifier before a real send. Four evals
enforce that a VERIFIED row is backed by its four checks and quotes its evidence.

Co-Authored-By: Claude <noreply@anthropic.com>
@gitguardian

gitguardian Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
35631047 Triggered Company Email Password f263053 leadgen/target_shortlist.csv View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant