Skip to content

Lead enrichment: names from addresses, LinkedIn profile and title by search, PDL behind a key - #125

Merged
ralyodio merged 4 commits into
mainfrom
feat/lead-enrichment
Oct 6, 2026
Merged

ralyodio merged 4 commits into
mainfrom
feat/lead-enrichment

Conversation

@ralyodio

@ralyodio ralyodio commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Free enrichment for leads that arrived as a bare address, ported from cli-tools linkedin-lookup / user-clean (logic and tests).

What fills what (blanks only, live-campaign leads)

  1. Names from the address, free: scott.perry@, gabriella_fiore@, ann-lee@ (two alphabetic parts of 2+ letters, no role or bot words). jsmith@, john.s@, john.smith42@ are left alone.
  2. People Data Labs by email, only with PDL_API_KEY (PDL's license allows storing results; Apollo's does not and is never used). 3/day per workspace by default (PDL_LOOKUPS_PER_DAY).
  3. Google search of LinkedIn through the existing VALUESERP_API_KEY: site:linkedin.com/in "First Last" <company>. A profile is taken only when the result title carries every name part and the company is in the title or snippet; with no company, only a single profile whose slug is the name. The job title comes off Name - Title - Company | LinkedIn. The company's linkedin.com/company/... page is the fallback (companies.linkedin_url).

Profiles are recorded as research (confidence 0.6 search / 0.8 PDL, below the outreach floor), with field_provenance rows. Nothing fetches LinkedIn itself.

Cost controls

  • Raw results cached in serp_cache keyed by query: reruns and rule changes cost nothing.
  • Caps: per run (max_searches, 40 for the background sweep, 100 on demand) and per workspace per day (LINKEDIN_SEARCHES_PER_DAY, default 300), counted in enrichment_usage so a restart cannot reset them.
  • Concurrency 10 (LEAD_ENRICH_CONCURRENCY), 45 s per-request timeout. A 402 throws SearchOutOfCredits and pauses that provider until the next UTC day; a person whose question went unanswered is not stamped and is retried.

Surfaces

  • Worker: sweep beside the tick, one run at a time.
  • API: POST /autogtm/campaigns/{id}/enrich (202, background), GET /autogtm/campaigns/{id}/enrichment (missing name/title/LinkedIn, providers, searches today vs cap, last run). In llms.txt/openapi.
  • UI: "Missing: N titles, M LinkedIn, K names. Find them" on the product page.
  • CLI: og leads enrich <campaignId> [--max N], og leads enrichment <campaignId>.
  • MCP: enrich_campaign_leads, get_lead_enrichment.

Migration 0052: two nullable ADD COLUMNs and two new empty tables; no index on an existing table. PDL_API_KEY joins the deploy's secret sync; it is not set yet, so PDL stays off until a key is assigned.

Tests

packages/providers/src/valueserp/linkedin.test.ts (matching rules, title parsing, 402 for ValueSERP and PDL), packages/pipeline/src/lead-enrichment.test.ts (names/title/profile/company page, cache makes the rerun free, cap and 402 stop cleanly, PDL first within its cap), API background run test, CLI/MCP tests.

🤖 Generated with Claude Code

ralyodio and others added 4 commits October 6, 2026 19:05
…reening

Append: POST /api/v1/autogtm/campaigns/{id}/leads takes up to 5,000 leads
(JSON or the CSV itself), skips people already in the campaign or its
project and anyone on a suppress list, and shares one code path with the
new-campaign import. UI on the product page, `og leads add`, MCP
add_leads_to_campaign.

Report: every row that did not simply become a lead is stored with what
happened (rejected, skipped, flagged) and the column or check behind it;
returned inline, readable at .../import/{task}/report, downloadable as CSV.

Screening: generated names (Borer853), relay mailboxes (passmail, aleeas,
simplelogin...), temp-mail domains (known, or one domain shared by 3+
unrelated leads), bot/agent/test accounts and role inboxes are flagged at
import and held back from cold outreach through the policy engine
(personScreenedOut), on every send path, until a human allows them.
Webmail is never taken as a company domain. Migration 0051.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…search, PDL behind a key

Fills only blanks, for leads in live campaigns: first/last from unambiguous
first.last@ addresses (free); People Data Labs by email when PDL_API_KEY is
set (licensed for storage, 3/day by default); then a Google search of
LinkedIn through the existing ValueSERP key, taking a profile only when the
result title carries every name part and the company (or, with no company,
one profile whose slug is the name), the title off "Name - Title - Company",
and the company's LinkedIn page as fallback. Ported from cli-tools
linkedin-lookup with its tests.

Every search is cached in serp_cache (reruns are free), capped per run and
per workspace per day (enrichment_usage), run at concurrency 10 with a 45 s
timeout, and a 402 pauses the provider until the next UTC day. A worker sweep
runs beside the tick; POST /autogtm/campaigns/{id}/enrich starts one on
demand and GET .../enrichment reports what is missing. UI on the product
page, `og leads enrich|enrichment`, MCP enrich_campaign_leads and
get_lead_enrichment. Migration 0052. PDL_API_KEY added to the deploy's
secret sync (unset until a key is assigned).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	apps/api/src/app.ts
#	apps/api/src/append-leads.test.ts
#	apps/api/src/autogtm-docs.ts
#	apps/api/src/autogtm.ts
#	apps/cli/src/commands.ts
#	apps/mcp/src/mcp.test.ts
#	apps/mcp/src/tools.ts
#	apps/web/app/(app)/products/[id]/page.tsx
#	packages/pipeline/src/index.ts
… mailbox

Seen live on the first prod append: info@ came back as 'nobody reads that
mailbox', the cleaner's text for noreply@.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ralyodio
ralyodio merged commit e07fb69 into main Oct 6, 2026
4 checks passed
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